Merge master into feat/workflow: resolve conflicts by keeping workflow branch changes

This commit is contained in:
Typer_Body
2026-05-21 00:51:09 +08:00
199 changed files with 39329 additions and 4604 deletions
+179
View File
@@ -0,0 +1,179 @@
# 单元测试覆盖率排除说明
## 排除范围
以下外部适配器模块不纳入测试覆盖目标,因为它们需要实际外部环境才能测试:
### 1. 消息平台适配器 (`platform/sources/`)
- **路径**: `src/langbot/pkg/platform/sources/`
- **模块**: aiocqhttp, dingtalk, discord, feishu, gestep, kook, lark, slack, telegram, wecom, wechatpv, wechatmp, qqbot
- **排除原因**: 需要真实消息平台账号和 webhook 连接,无法纯单元测试
- **测试方式**: 需要 mock 平台 API 或集成测试环境
- **状态**: 后续可补充 mock 测试
### 2. LLM Requester (`provider/modelmgr/requesters/`)
- **路径**: `src/langbot/pkg/provider/modelmgr/requesters/`
- **模块**: deepseek, openai, anthropic, gemini, moonshot, ollama, zhipuai 等 20+ 个 requester
- **排除原因**: 需要真实 LLM API 密钥和网络请求,涉及付费 API 调用
- **测试方式**: 需要 mock HTTP 响应或使用 fake LLM server
- **状态**: 后续可补充 mock HTTP 测试
### 3. Agent Runner (`provider/runners/`)
- **路径**: `src/langbot/pkg/provider/runners/`
- **模块**: cozeapi, difysvapi, n8nsvapi, langflowapi, dashscopeapi, localagent, tboxapi
- **排除原因**: 需要真实 Agent 平台(Coze、Dify、n8n 等)的 API 连接
- **测试方式**: 需要 mock Agent 平台响应
- **状态**: 后续可补充 mock 测试
### 4. 向量数据库 (`vector/vdbs/`)
- **路径**: `src/langbot/pkg/vector/vdbs/`
- **模块**: chroma, milvus, pgvector, qdrant, seekdb
- **排除原因**: 需要真实向量数据库实例运行
- **测试方式**: 需要 Docker 启动测试数据库或 mock
- **状态**: 后续可补充 mock 测试
---
## 覆盖率计算(排除外部适配器)
### 统计方法
```bash
# 排除外部适配器后计算覆盖率
pytest tests/unit_tests/ --cov=langbot.pkg \
--cov-fail-under=0 \
-o "cov_exclude_patterns=platform/sources/*,provider/modelmgr/requesters/*,provider/runners/*,vector/vdbs/*"
```
### 当前覆盖率(排除后)
| 模块 | 覆盖率 | 状态 |
|------|--------|------|
| `command` | **99%** | ✅ 完成 |
| `entity` | **99%** | ✅ 完成 |
| `vector` | **76%** | ✅ 完成 |
| `survey` | **84%** | ✅ 完成 |
| `pipeline` | **72%** | ✅ 核心流程 |
| `rag` | **66%** | ✅ 完成 |
| `telemetry` | **87%** | ✅ 完成 |
| `storage` | **80%** | ✅ 完成 |
| `provider` | **83%** | ✅ 完成 |
| `discover` | **61%** | ✅ 完成 |
| `config` | **70%** | ✅ 完成 |
| `utils` | **48%** | 🔄 部分完成 |
| `api` | **34%** | 🔄 需补充 controller |
| `platform` | **35%** | 🔄 需补充 adapter base |
| `plugin` | **27%** | 🔄 需补充 handler |
| `core` | **28%** | 🔄 需补充 app 启动 |
| `persistence` | **24%** | 🔄 需补充 mgr |
---
## 后续计划
### 可补充的 Mock 测试(优先级排序)
1. **`provider/modelmgr/requesters/`** (优先级:中)
- 使用 `httpx` mock 测试 API 响应解析
- 测试重试逻辑、错误处理
2. **`provider/runners/`** (优先级:中)
- Mock Agent 平台响应
- 测试 session 管理、错误处理
3. **`platform/sources/`** (优先级:低)
- Mock 平台 webhook 事件
- 测试消息解析、事件处理
4. **`vector/vdbs/`** (优先级:低)
- Mock 向量数据库操作
- 测试 CRUD、查询逻辑
---
## 测试文件结构
```
tests/unit_tests/
├── api/
│ └── service/
│ ├── test_knowledge_service.py # 22 tests ✅
│ └── ...
├── core/
│ ├── test_taskmgr.py # 21 tests ✅
│ ├── test_load_config.py # 21 tests ✅ (含env override)
│ └── ...
├── plugin/
│ ├── test_connector_static.py # 8 tests ✅
│ ├── test_connector_pure.py # 7 tests ✅
│ ├── test_connector_methods.py # 24 tests ✅
│ ├── test_extract_deps.py # 7 tests ✅
│ ├── test_handler_actions.py # 15 tests ✅ (新增)
│ └── ...
├── provider/
│ ├── test_session_manager.py # 11 tests ✅ (新增)
│ ├── test_tool_manager.py # 14 tests ✅ (新增)
│ └── ...
├── rag/
│ ├── test_i18n_conversion.py # 8 tests ✅
│ ├── test_kbmgr.py # 39 tests ✅
│ ├── test_file_storage.py # 21 tests ✅ (新增)
│ └── ...
├── storage/
│ ├── test_s3storage.py # 16 tests ✅ (新增)
│ ├── test_localstorage_path_traversal.py # 11 tests ✅
│ └── ...
├── survey/
│ └── test_survey_manager.py # 22 tests ✅
├── telemetry/
│ └── test_telemetry.py # 25 tests ✅ (重写)
├── vector/
│ ├── test_filter_utils.py # 21 tests ✅
│ ├── test_vdb_filter_conversion.py # 30 tests ✅ (新增)
│ └── ...
├── utils/
│ ├── test_platform.py # 7 tests ✅
│ ├── test_funcschema.py # 9 tests ✅
│ └── ...
├── pipeline/
│ ├── test_ratelimit.py # 12 tests ✅ (新增真实算法)
│ ├── test_msgtrun.py # 9 tests ✅ (强化断言)
│ └── ...
└── persistence/
├── test_serialize_model.py # 6 tests ✅
├── test_database_decorator.py # 7 tests ✅
└── ...
```
---
## 总结
- **总测试数**: 1193 passed
- **总体覆盖率**: 30%
- **核心模块覆盖率**: **51.2%** (6549/12825 语句) - 排除外部适配器
- **外部适配器覆盖率**: 5.6% (535/9483 语句) - 不纳入目标
### 核心模块覆盖率详情
| 模块 | 覆盖率 | 语句数 | 说明 |
|------|--------|--------|------|
| `command` | **99%** | 93 | ✅ 完成 |
| `entity` | **99%** | 335 | ✅ 完成 |
| `vector` | **76%** | 139 | ✅ 完成 (新增filter转换测试) |
| `survey` | **84%** | 95 | ✅ 完成 |
| `pipeline` | **72%** | 1761 | ✅ 核心流程 (新增算法测试) |
| `rag` | **66%** | 347 | ✅ 完成 (新增ZIP处理测试) |
| `telemetry` | **87%** | 70 | ✅ 完成 (重写假测试) |
| `storage` | **80%** | 170 | ✅ 完成 (新增S3测试) |
| `provider` | **83%** | 854 | ✅ 完成 (新增Session/Tool测试) |
| `discover` | **61%** | 188 | ✅ 完成 |
| `config` | **70%** | 198 | ✅ 完成 |
| `utils` | **48%** | 478 | 🔄 部分完成 |
| `api` | **34%** | 4061 | 🔄 需补充 controller |
| `platform` | **35%** | 433 | 🔄 需补充 adapter base |
| `plugin` | **27%** | 815 | 🔄 需补充 handler (新增action测试) |
| `core` | **28%** | 1289 | 🔄 需补充 app 启动 |
| `persistence` | **24%** | 1099 | 🔄 需补充 mgr |
外部适配器测试需要 mock 环境或集成测试,不属于纯单元测试范畴。
+1
View File
@@ -0,0 +1 @@
"""Unit tests for LangBot API HTTP service layer."""
@@ -0,0 +1,62 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
from sqlalchemy.sql.dml import Update
from langbot.pkg.api.http.service.bot import BotService
class _FakeResult:
def __init__(self, value):
self.value = value
def first(self):
return self.value
class _PersistenceManager:
def __init__(self):
self.update_values = None
async def execute_async(self, statement):
if isinstance(statement, Update):
self.update_values = {
key: value for key, value in statement.compile().params.items() if not key.startswith('uuid_')
}
return None
return _FakeResult(SimpleNamespace(name='Updated Pipeline'))
async def test_update_bot_copies_input_before_filtering_and_setting_pipeline_name():
persistence_mgr = _PersistenceManager()
runtime_bot = SimpleNamespace(enable=False)
platform_mgr = SimpleNamespace(
remove_bot=AsyncMock(),
load_bot=AsyncMock(return_value=runtime_bot),
)
ap = SimpleNamespace(
persistence_mgr=persistence_mgr,
platform_mgr=platform_mgr,
sess_mgr=SimpleNamespace(session_list=[]),
)
service = BotService(ap)
service.get_bot = AsyncMock(return_value={'uuid': 'bot-1'})
payload = {
'uuid': 'caller-owned-uuid',
'name': 'Test Bot',
'use_pipeline_uuid': 'pipeline-1',
}
await service.update_bot('bot-1', payload)
assert payload == {
'uuid': 'caller-owned-uuid',
'name': 'Test Bot',
'use_pipeline_uuid': 'pipeline-1',
}
assert persistence_mgr.update_values == {
'name': 'Test Bot',
'use_pipeline_uuid': 'pipeline-1',
'use_pipeline_name': 'Updated Pipeline',
}
+16
View File
@@ -0,0 +1,16 @@
"""Unit tests for API HTTP service layer.
Tests real service business logic with mocked dependencies:
- persistence_mgr (database operations)
- model_mgr (runtime model management)
- platform_mgr (platform management)
- plugin_connector (plugin runtime)
- adjacent services (cross-service calls)
Does NOT:
- Start real Quart server
- Access real database
- Call real provider/platform/network
Uses tests.factories.FakeApp as base mock application.
"""
@@ -0,0 +1,429 @@
"""
Unit tests for ApiKeyService.
Tests API key CRUD operations with mocked persistence layer.
Source: src/langbot/pkg/api/http/service/apikey.py
"""
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock, patch
from types import SimpleNamespace
from langbot.pkg.api.http.service.apikey import ApiKeyService
from langbot.pkg.entity.persistence.apikey import ApiKey
pytestmark = pytest.mark.asyncio
class TestApiKeyServiceGetApiKeys:
"""Tests for get_api_keys method."""
async def test_get_api_keys_empty_list(self):
"""Returns empty list when no API keys exist."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = Mock()
mock_result.all = Mock(return_value=[])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda model_cls, entity: {
'id': entity.id,
'name': entity.name,
'key': entity.key,
'description': entity.description,
}
if entity
else {}
)
service = ApiKeyService(ap)
# Execute
result = await service.get_api_keys()
# Verify
assert result == []
ap.persistence_mgr.execute_async.assert_called_once()
async def test_get_api_keys_returns_serialized_list(self):
"""Returns serialized list of API keys."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
# Create mock API key entities
key1 = Mock(spec=ApiKey)
key1.id = 1
key1.name = 'Test Key 1'
key1.key = 'lbk_test_key_1'
key1.description = 'First test key'
key2 = Mock(spec=ApiKey)
key2.id = 2
key2.name = 'Test Key 2'
key2.key = 'lbk_test_key_2'
key2.description = 'Second test key'
mock_result = Mock()
mock_result.all = Mock(return_value=[key1, key2])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda model_cls, entity: {
'id': entity.id,
'name': entity.name,
'key': entity.key,
'description': entity.description,
}
)
service = ApiKeyService(ap)
# Execute
result = await service.get_api_keys()
# Verify
assert len(result) == 2
assert result[0]['name'] == 'Test Key 1'
assert result[1]['name'] == 'Test Key 2'
class TestApiKeyServiceCreateApiKey:
"""Tests for create_api_key method."""
async def test_create_api_key_generates_key_with_prefix(self):
"""Creates API key with 'lbk_' prefix."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
created_key = Mock(spec=ApiKey)
created_key.id = 1
created_key.name = 'New 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 = []
async def mock_execute(query):
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(
side_effect=lambda model_cls, entity: {
'id': 1,
'name': entity.name,
'key': entity.key,
'description': entity.description,
}
)
service = ApiKeyService(ap)
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')
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'
async def test_create_api_key_without_description(self):
"""Creates API key with empty description when not provided."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
created_key = Mock(spec=ApiKey)
created_key.id = 1
created_key.name = 'No Desc Key'
created_key.key = 'lbk_no_desc_key'
created_key.description = ''
select_result = Mock()
select_result.first = Mock(return_value=created_key)
insert_result = Mock()
async def mock_execute(query):
if hasattr(query, 'values'):
return insert_result
return select_result
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(
return_value={
'id': 1,
'name': 'No Desc Key',
'key': 'lbk_no_desc_key',
'description': '',
}
)
service = ApiKeyService(ap)
# Execute
result = await service.create_api_key('No Desc Key')
# Verify
assert result['description'] == ''
class TestApiKeyServiceGetApiKey:
"""Tests for get_api_key method."""
async def test_get_api_key_by_id_found(self):
"""Returns API key when found by ID."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
key = Mock(spec=ApiKey)
key.id = 1
key.name = 'Found Key'
key.key = 'lbk_found_key'
key.description = 'Found'
mock_result = Mock()
mock_result.first = Mock(return_value=key)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
return_value={
'id': 1,
'name': 'Found Key',
'key': 'lbk_found_key',
'description': 'Found',
}
)
service = ApiKeyService(ap)
# Execute
result = await service.get_api_key(1)
# Verify
assert result is not None
assert result['id'] == 1
assert result['name'] == 'Found Key'
async def test_get_api_key_by_id_not_found(self):
"""Returns None when API key not found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = Mock()
mock_result.first = Mock(return_value=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = ApiKeyService(ap)
# Execute
result = await service.get_api_key(999)
# Verify
assert result is None
async def test_get_api_key_by_id_zero(self):
"""Handles ID=0 (edge case) correctly."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = Mock()
mock_result.first = Mock(return_value=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = ApiKeyService(ap)
# Execute
result = await service.get_api_key(0)
# Verify - should return None (no key with ID 0)
assert result is None
class TestApiKeyServiceVerifyApiKey:
"""Tests for verify_api_key method."""
async def test_verify_api_key_valid(self):
"""Returns True for valid API key."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
key = Mock(spec=ApiKey)
mock_result = Mock()
mock_result.first = Mock(return_value=key)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = ApiKeyService(ap)
# Execute
result = await service.verify_api_key('lbk_valid_key')
# Verify
assert result is True
async def test_verify_api_key_invalid(self):
"""Returns False for invalid API key."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = Mock()
mock_result.first = Mock(return_value=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = ApiKeyService(ap)
# Execute
result = await service.verify_api_key('lbk_invalid_key')
# Verify
assert result is False
async def test_verify_api_key_empty_string(self):
"""Returns False for empty key string."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = Mock()
mock_result.first = Mock(return_value=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = ApiKeyService(ap)
# Execute
result = await service.verify_api_key('')
# Verify
assert result is False
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()
mock_result = Mock()
mock_result.first = Mock(return_value=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = ApiKeyService(ap)
# Execute
result = await service.verify_api_key('unknown_key')
# Verify
assert result is False
class TestApiKeyServiceDeleteApiKey:
"""Tests for delete_api_key method."""
async def test_delete_api_key_by_id(self):
"""Deletes API key by ID."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock()
service = ApiKeyService(ap)
# Execute
await service.delete_api_key(1)
# Verify - execute_async was called (delete operation)
ap.persistence_mgr.execute_async.assert_called_once()
async def test_delete_api_key_nonexistent_id(self):
"""Delete operation completes even for nonexistent ID (no error raised)."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock()
service = ApiKeyService(ap)
# Execute - should not raise error
await service.delete_api_key(999)
# Verify - execute_async was called regardless
ap.persistence_mgr.execute_async.assert_called_once()
class TestApiKeyServiceUpdateApiKey:
"""Tests for update_api_key method."""
async def test_update_api_key_name_only(self):
"""Updates only the name field."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock()
service = ApiKeyService(ap)
# Execute
await service.update_api_key(1, name='Updated Name')
# Verify - execute_async was called with update
ap.persistence_mgr.execute_async.assert_called_once()
async def test_update_api_key_description_only(self):
"""Updates only the description field."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock()
service = ApiKeyService(ap)
# Execute
await service.update_api_key(1, description='Updated description')
# Verify
ap.persistence_mgr.execute_async.assert_called_once()
async def test_update_api_key_both_fields(self):
"""Updates both name and description."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock()
service = ApiKeyService(ap)
# Execute
await service.update_api_key(1, name='New Name', description='New description')
# Verify
ap.persistence_mgr.execute_async.assert_called_once()
async def test_update_api_key_no_fields(self):
"""Does nothing when no fields provided."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock()
service = ApiKeyService(ap)
# Execute
await service.update_api_key(1)
# Verify - no execute call since no update_data
ap.persistence_mgr.execute_async.assert_not_called()
@@ -0,0 +1,662 @@
"""
Unit tests for BotService.
Tests bot CRUD operations with mocked persistence and runtime managers.
Source: src/langbot/pkg/api/http/service/bot.py
"""
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock, patch
from types import SimpleNamespace
import uuid
from langbot.pkg.api.http.service.bot import BotService
from langbot.pkg.entity.persistence.bot import Bot
pytestmark = pytest.mark.asyncio
def _create_mock_bot(
bot_uuid: str = None,
name: str = 'Test Bot',
description: str = 'Test Description',
adapter: str = 'telegram',
adapter_config: dict = None,
enable: bool = True,
use_pipeline_uuid: str = None,
use_pipeline_name: str = None,
) -> Mock:
"""Helper to create mock Bot entity."""
bot = Mock(spec=Bot)
bot.uuid = bot_uuid or str(uuid.uuid4())
bot.name = name
bot.description = description
bot.adapter = adapter
bot.adapter_config = adapter_config or {'token': 'test_token'}
bot.enable = enable
bot.use_pipeline_uuid = use_pipeline_uuid
bot.use_pipeline_name = use_pipeline_name
bot.pipeline_routing_rules = []
return bot
def _create_mock_result(items: list = None, first_item=None):
"""Create mock result object for persistence queries."""
result = Mock()
result.all = Mock(return_value=items or [])
result.first = Mock(return_value=first_item)
return result
class TestBotServiceGetBots:
"""Tests for get_bots method."""
async def test_get_bots_empty_list(self):
"""Returns empty list when no bots exist."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda model_cls, entity, masked_columns=None: {
'uuid': entity.uuid,
'name': entity.name,
'adapter': entity.adapter,
}
)
service = BotService(ap)
# Execute
result = await service.get_bots()
# Verify
assert result == []
async def test_get_bots_returns_list_with_secrets(self):
"""Returns bot list including adapter_config by default."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
bot1 = _create_mock_bot(bot_uuid='uuid-1', name='Bot 1')
bot2 = _create_mock_bot(bot_uuid='uuid-2', name='Bot 2')
mock_result = _create_mock_result([bot1, bot2])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda model_cls, entity, masked_columns=None: {
'uuid': entity.uuid,
'name': entity.name,
'adapter': entity.adapter,
'adapter_config': entity.adapter_config if 'adapter_config' not in (masked_columns or []) else None,
}
)
service = BotService(ap)
# Execute
result = await service.get_bots(include_secret=True)
# Verify
assert len(result) == 2
assert result[0]['name'] == 'Bot 1'
assert result[0]['adapter_config'] is not None
async def test_get_bots_masks_secrets(self):
"""Returns bot list without adapter_config when include_secret=False."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
bot1 = _create_mock_bot(bot_uuid='uuid-1', name='Bot 1')
mock_result = _create_mock_result([bot1])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda model_cls, entity, masked_columns=None: {
'uuid': entity.uuid,
'name': entity.name,
'adapter': entity.adapter,
'adapter_config': entity.adapter_config if 'adapter_config' not in (masked_columns or []) else None,
}
)
service = BotService(ap)
# Execute
result = await service.get_bots(include_secret=False)
# Verify - adapter_config should be masked
assert result[0]['adapter_config'] is None
class TestBotServiceGetBot:
"""Tests for get_bot method."""
async def test_get_bot_by_uuid_found(self):
"""Returns bot when found by UUID."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
bot = _create_mock_bot(bot_uuid='test-uuid', name='Found Bot')
mock_result = _create_mock_result(first_item=bot)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
return_value={
'uuid': 'test-uuid',
'name': 'Found Bot',
'adapter': 'telegram',
}
)
service = BotService(ap)
# Execute
result = await service.get_bot('test-uuid')
# Verify
assert result is not None
assert result['uuid'] == 'test-uuid'
assert result['name'] == 'Found Bot'
async def test_get_bot_by_uuid_not_found(self):
"""Returns None when bot not found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result(first_item=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = BotService(ap)
# Execute
result = await service.get_bot('nonexistent-uuid')
# Verify
assert result is None
class TestBotServiceGetRuntimeBotInfo:
"""Tests for get_runtime_bot_info method."""
async def test_get_runtime_bot_info_bot_not_found_raises(self):
"""Raises Exception when bot not found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result(first_item=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = BotService(ap)
# Mock get_bot to return None
service.get_bot = AsyncMock(return_value=None)
# Execute & Verify
with pytest.raises(Exception, match='Bot not found'):
await service.get_runtime_bot_info('nonexistent-uuid')
async def test_get_runtime_bot_info_returns_webhook_for_wecom(self):
"""Returns webhook URL for wecom adapter."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {
'api': {
'webhook_prefix': 'http://127.0.0.1:5300',
'extra_webhook_prefix': 'http://extra.example.com',
}
}
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None)
bot_data = {
'uuid': 'wecom-uuid',
'name': 'WeCom Bot',
'adapter': 'wecom',
'adapter_config': {'token': 'test'},
}
service = BotService(ap)
service.get_bot = AsyncMock(return_value=bot_data)
# Execute
result = await service.get_runtime_bot_info('wecom-uuid')
# Verify
assert result['adapter_runtime_values']['webhook_url'] == '/bots/wecom-uuid'
assert result['adapter_runtime_values']['webhook_full_url'] == 'http://127.0.0.1:5300/bots/wecom-uuid'
async def test_get_runtime_bot_info_no_webhook_for_telegram(self):
"""Returns no webhook URL for non-webhook adapters like telegram."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'api': {}}
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None)
bot_data = {
'uuid': 'telegram-uuid',
'name': 'Telegram Bot',
'adapter': 'telegram',
'adapter_config': {'token': 'test'},
}
service = BotService(ap)
service.get_bot = AsyncMock(return_value=bot_data)
# Execute
result = await service.get_runtime_bot_info('telegram-uuid')
# Verify - no webhook for telegram
assert result['adapter_runtime_values']['webhook_url'] is None
assert result['adapter_runtime_values']['webhook_full_url'] is None
async def test_get_runtime_bot_info_with_runtime_bot(self):
"""Returns bot_account_id when runtime bot exists."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'api': {}}
ap.platform_mgr = SimpleNamespace()
# Mock runtime bot with adapter
runtime_bot = SimpleNamespace()
runtime_bot.adapter = SimpleNamespace()
runtime_bot.adapter.bot_account_id = 'runtime-account-123'
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=runtime_bot)
bot_data = {
'uuid': 'runtime-uuid',
'name': 'Runtime Bot',
'adapter': 'telegram',
'adapter_config': {},
}
service = BotService(ap)
service.get_bot = AsyncMock(return_value=bot_data)
# Execute
result = await service.get_runtime_bot_info('runtime-uuid')
# Verify
assert result['adapter_runtime_values']['bot_account_id'] == 'runtime-account-123'
class TestBotServiceCreateBot:
"""Tests for create_bot method."""
async def test_create_bot_max_limit_reached_raises(self):
"""Raises ValueError when max_bots limit reached."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {
'system': {
'limitation': {
'max_bots': 2
}
}
}
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.load_bot = AsyncMock()
# Mock get_bots to return 2 bots already
bot1 = _create_mock_bot(bot_uuid='uuid-1')
bot2 = _create_mock_bot(bot_uuid='uuid-2')
mock_result = _create_mock_result([bot1, bot2])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
return_value={'uuid': 'uuid-1', 'name': 'Bot 1'}
)
service = BotService(ap)
# Execute & Verify
with pytest.raises(ValueError, match='Maximum number of bots'):
await service.create_bot({'name': 'New Bot'})
async def test_create_bot_no_limit(self):
"""Creates bot without limit check when max_bots=-1."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {
'system': {
'limitation': {
'max_bots': -1 # No limit
}
}
}
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.load_bot = AsyncMock()
# Mock pipeline query
pipeline_result = Mock()
pipeline_result.first = Mock(return_value=None)
# Mock bot query after insert
bot_result = Mock()
bot_result.first = Mock(return_value=_create_mock_bot())
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count <= 2:
return pipeline_result # First call: check pipeline
elif call_count == 3:
return Mock() # Insert
return bot_result # Get bot
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(
return_value={'uuid': 'new-uuid', 'name': 'New Bot'}
)
service = BotService(ap)
# Execute
bot_uuid = await service.create_bot({'name': 'New Bot', 'adapter': 'telegram', 'adapter_config': {}})
# Verify
assert bot_uuid is not None
assert len(bot_uuid) == 36 # UUID format
async def test_create_bot_sets_default_pipeline(self):
"""Sets default pipeline when one exists."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'limitation': {'max_bots': -1}}}
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.load_bot = AsyncMock()
# Mock default pipeline
mock_pipeline = SimpleNamespace()
mock_pipeline.uuid = 'default-pipeline-uuid'
mock_pipeline.name = 'Default Pipeline'
pipeline_result = Mock()
pipeline_result.first = Mock(return_value=mock_pipeline)
# Mock bot after insert
bot_result = Mock()
bot_result.first = Mock(return_value=_create_mock_bot())
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return pipeline_result # Check default pipeline
elif call_count == 2:
return Mock() # Insert
return bot_result # Get bot
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(
return_value={
'uuid': 'new-uuid',
'name': 'New Bot',
'use_pipeline_uuid': 'default-pipeline-uuid',
'use_pipeline_name': 'Default Pipeline',
}
)
service = BotService(ap)
# Execute
bot_data = {'name': 'New Bot', 'adapter': 'telegram', 'adapter_config': {}}
bot_uuid = await service.create_bot(bot_data)
# Verify - pipeline uuid and name were set
assert 'use_pipeline_uuid' in bot_data
assert 'use_pipeline_name' in bot_data
assert bot_uuid is not None # Verify UUID was returned
class TestBotServiceUpdateBot:
"""Tests for update_bot method."""
async def test_update_bot_removes_uuid_from_data(self):
"""Does not persist caller-provided uuid in update payload."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.remove_bot = AsyncMock()
# Mock pipeline query - not updating pipeline
ap.persistence_mgr.execute_async = AsyncMock()
ap.sess_mgr = SimpleNamespace()
ap.sess_mgr.session_list = []
service = BotService(ap)
service.get_bot = AsyncMock(return_value={'uuid': 'test-uuid', 'name': 'Updated'})
# Create mock runtime bot
runtime_bot = SimpleNamespace()
runtime_bot.enable = False
ap.platform_mgr.load_bot = AsyncMock(return_value=runtime_bot)
# Execute
update_data = {'uuid': 'should-be-removed', 'name': 'Updated Name'}
await service.update_bot('test-uuid', 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."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
# Mock pipeline query returns None
pipeline_result = Mock()
pipeline_result.first = Mock(return_value=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=pipeline_result)
service = BotService(ap)
# Execute & Verify
with pytest.raises(Exception, match='Pipeline not found'):
await service.update_bot('test-uuid', {'use_pipeline_uuid': 'nonexistent-pipeline'})
async def test_update_bot_sets_pipeline_name(self):
"""Sets use_pipeline_name when updating use_pipeline_uuid."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.remove_bot = AsyncMock()
# Mock pipeline query
mock_pipeline = SimpleNamespace()
mock_pipeline.name = 'Updated Pipeline'
pipeline_result = Mock()
pipeline_result.first = Mock(return_value=mock_pipeline)
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return pipeline_result
return Mock()
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.sess_mgr = SimpleNamespace()
ap.sess_mgr.session_list = []
service = BotService(ap)
service.get_bot = AsyncMock(return_value={'uuid': 'test-uuid'})
runtime_bot = SimpleNamespace()
runtime_bot.enable = False
ap.platform_mgr.load_bot = AsyncMock(return_value=runtime_bot)
# Execute
await service.update_bot('test-uuid', {'use_pipeline_uuid': 'pipeline-uuid'})
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:
"""Tests for delete_bot method."""
async def test_delete_bot_calls_remove_and_delete(self):
"""Calls both platform_mgr.remove_bot and persistence delete."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock()
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.remove_bot = AsyncMock()
service = BotService(ap)
# Execute
await service.delete_bot('test-uuid')
# Verify
ap.platform_mgr.remove_bot.assert_called_once_with('test-uuid')
ap.persistence_mgr.execute_async.assert_called_once()
async def test_delete_bot_nonexistent_uuid(self):
"""Delete operation completes even for nonexistent UUID."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock()
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.remove_bot = AsyncMock()
service = BotService(ap)
# Execute - should not raise
await service.delete_bot('nonexistent-uuid')
# Verify - both called regardless
ap.platform_mgr.remove_bot.assert_called_once()
class TestBotServiceListEventLogs:
"""Tests for list_event_logs method."""
async def test_list_event_logs_bot_not_found_raises(self):
"""Raises Exception when runtime bot not found."""
# Setup
ap = SimpleNamespace()
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None)
service = BotService(ap)
# Execute & Verify
with pytest.raises(Exception, match='Bot not found'):
await service.list_event_logs('nonexistent-uuid', 0, 10)
async def test_list_event_logs_returns_logs(self):
"""Returns logs from runtime bot logger."""
# Setup
ap = SimpleNamespace()
ap.platform_mgr = SimpleNamespace()
# Mock runtime bot with logger
runtime_bot = SimpleNamespace()
runtime_bot.logger = SimpleNamespace()
runtime_bot.logger.get_logs = AsyncMock(return_value=(
[SimpleNamespace(to_json=Mock(return_value={'msg': 'log1'}))],
5
))
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=runtime_bot)
service = BotService(ap)
# Execute
logs, total = await service.list_event_logs('bot-uuid', 0, 10)
# Verify
assert len(logs) == 1
assert logs[0] == {'msg': 'log1'}
assert total == 5
class TestBotServiceSendMessage:
"""Tests for send_message method."""
async def test_send_message_bot_not_found_raises(self):
"""Raises Exception when bot not found."""
# Setup
ap = SimpleNamespace()
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None)
service = BotService(ap)
# Execute & Verify
with pytest.raises(Exception, match='Bot not found'):
await service.send_message('nonexistent-uuid', 'group', '123', {'test': 'data'})
async def test_send_message_invalid_message_chain_raises(self):
"""Raises Exception when message_chain_data is invalid."""
# Setup
ap = SimpleNamespace()
ap.platform_mgr = SimpleNamespace()
runtime_bot = SimpleNamespace()
runtime_bot.adapter = SimpleNamespace()
runtime_bot.adapter.send_message = AsyncMock()
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=runtime_bot)
service = BotService(ap)
# Execute & Verify - invalid format should raise
with pytest.raises(Exception, match='Invalid message_chain format'):
await service.send_message('bot-uuid', 'group', '123', {'invalid': 'format'})
async def test_send_message_valid_call(self):
"""Sends message through adapter when all valid."""
# Setup
ap = SimpleNamespace()
ap.platform_mgr = SimpleNamespace()
runtime_bot = SimpleNamespace()
runtime_bot.adapter = SimpleNamespace()
runtime_bot.adapter.send_message = AsyncMock()
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=runtime_bot)
service = BotService(ap)
# Execute with valid message chain format
message_chain_data = {
'messages': [
{'type': 'text', 'data': {'text': 'Hello'}}
]
}
# Patch the import location - the module imports inside the function
with patch('langbot_plugin.api.entities.builtin.platform.message.MessageChain') as MockMessageChain:
mock_chain = Mock()
MockMessageChain.model_validate = Mock(return_value=mock_chain)
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)
@@ -0,0 +1,397 @@
"""Unit tests for API knowledge service.
Tests cover:
- Knowledge base CRUD operations
- Capability checking
- Knowledge engine discovery
- File operations
"""
from __future__ import annotations
import pytest
from unittest.mock import Mock, AsyncMock
from importlib import import_module
def get_knowledge_service_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.api.http.service.knowledge')
def create_mock_app():
"""Create mock Application for testing."""
mock_app = Mock()
mock_app.logger = Mock()
mock_app.rag_mgr = AsyncMock()
mock_app.persistence_mgr = AsyncMock()
mock_app.persistence_mgr.execute_async = AsyncMock()
mock_app.persistence_mgr.serialize_model = Mock(return_value={})
mock_app.plugin_connector = AsyncMock()
mock_app.plugin_connector.is_enable_plugin = True
return mock_app
class TestKnowledgeServiceInit:
"""Tests for KnowledgeService initialization."""
def test_init_stores_app_reference(self):
"""Test that __init__ stores Application reference."""
knowledge_module = get_knowledge_service_module()
mock_app = create_mock_app()
service = knowledge_module.KnowledgeService(mock_app)
assert service.ap is mock_app
class TestGetKnowledgeBases:
"""Tests for get_knowledge_bases method."""
@pytest.mark.asyncio
async def test_returns_all_kb_details(self):
"""Test that it returns all knowledge base details."""
knowledge_module = get_knowledge_service_module()
mock_app = create_mock_app()
mock_app.rag_mgr.get_all_knowledge_base_details = AsyncMock(
return_value=[{'uuid': 'kb1', 'name': 'KB1'}]
)
service = knowledge_module.KnowledgeService(mock_app)
result = await service.get_knowledge_bases()
assert len(result) == 1
assert result[0]['uuid'] == 'kb1'
@pytest.mark.asyncio
async def test_returns_empty_list_when_no_kbs(self):
"""Test that it returns empty list when no knowledge bases."""
knowledge_module = get_knowledge_service_module()
mock_app = create_mock_app()
mock_app.rag_mgr.get_all_knowledge_base_details = AsyncMock(return_value=[])
service = knowledge_module.KnowledgeService(mock_app)
result = await service.get_knowledge_bases()
assert result == []
class TestGetKnowledgeBase:
"""Tests for get_knowledge_base method."""
@pytest.mark.asyncio
async def test_returns_kb_details_by_uuid(self):
"""Test that it returns specific KB details."""
knowledge_module = get_knowledge_service_module()
mock_app = create_mock_app()
mock_app.rag_mgr.get_knowledge_base_details = AsyncMock(
return_value={'uuid': 'kb1', 'name': 'KB1'}
)
service = knowledge_module.KnowledgeService(mock_app)
result = await service.get_knowledge_base('kb1')
assert result['uuid'] == 'kb1'
@pytest.mark.asyncio
async def test_returns_none_when_not_found(self):
"""Test that it returns None when KB not found."""
knowledge_module = get_knowledge_service_module()
mock_app = create_mock_app()
mock_app.rag_mgr.get_knowledge_base_details = AsyncMock(return_value=None)
service = knowledge_module.KnowledgeService(mock_app)
result = await service.get_knowledge_base('nonexistent')
assert result is None
class TestCreateKnowledgeBase:
"""Tests for create_knowledge_base method."""
@pytest.mark.asyncio
async def test_creates_kb_with_required_fields(self):
"""Test creating KB with required plugin ID."""
knowledge_module = get_knowledge_service_module()
mock_app = create_mock_app()
mock_kb = Mock()
mock_kb.uuid = 'new_kb_uuid'
mock_app.rag_mgr.create_knowledge_base = AsyncMock(return_value=mock_kb)
service = knowledge_module.KnowledgeService(mock_app)
kb_data = {
'name': 'Test KB',
'knowledge_engine_plugin_id': 'author/engine',
'description': 'Test description',
}
result = await service.create_knowledge_base(kb_data)
assert result == 'new_kb_uuid'
mock_app.rag_mgr.create_knowledge_base.assert_called_once()
@pytest.mark.asyncio
async def test_raises_when_missing_plugin_id(self):
"""Test that ValueError is raised when plugin ID missing."""
knowledge_module = get_knowledge_service_module()
mock_app = create_mock_app()
service = knowledge_module.KnowledgeService(mock_app)
with pytest.raises(ValueError) as exc_info:
await service.create_knowledge_base({'name': 'Test'})
assert 'knowledge_engine_plugin_id is required' in str(exc_info.value)
@pytest.mark.asyncio
async def test_creates_with_default_name(self):
"""Test that KB is created with default name if not provided."""
knowledge_module = get_knowledge_service_module()
mock_app = create_mock_app()
mock_kb = Mock()
mock_kb.uuid = 'new_kb_uuid'
mock_app.rag_mgr.create_knowledge_base = AsyncMock(return_value=mock_kb)
service = knowledge_module.KnowledgeService(mock_app)
await service.create_knowledge_base({
'knowledge_engine_plugin_id': 'author/engine'
})
# Check that default name 'Untitled' was used
call_args = mock_app.rag_mgr.create_knowledge_base.call_args
assert call_args.kwargs['name'] == 'Untitled'
class TestUpdateKnowledgeBase:
"""Tests for update_knowledge_base method."""
@pytest.mark.asyncio
async def test_updates_mutable_fields_only(self):
"""Test that only mutable fields are updated."""
knowledge_module = get_knowledge_service_module()
mock_app = create_mock_app()
mock_app.rag_mgr.get_knowledge_base_details = AsyncMock(
return_value={'uuid': 'kb1', 'name': 'Updated'}
)
mock_app.rag_mgr.remove_knowledge_base_from_runtime = AsyncMock()
mock_app.rag_mgr.load_knowledge_base = AsyncMock()
service = knowledge_module.KnowledgeService(mock_app)
# Pass both mutable and immutable fields
await service.update_knowledge_base('kb1', {
'name': 'New Name',
'description': 'New desc',
'uuid': 'should_be_filtered', # immutable
})
# Check that only mutable fields were passed to update
call_args = mock_app.persistence_mgr.execute_async.call_args
assert call_args is not None
@pytest.mark.asyncio
async def test_returns_early_when_no_mutable_fields(self):
"""Test that update returns early when no mutable fields provided."""
knowledge_module = get_knowledge_service_module()
mock_app = create_mock_app()
service = knowledge_module.KnowledgeService(mock_app)
# Pass only immutable fields
await service.update_knowledge_base('kb1', {'uuid': 'should_be_filtered'})
# No DB update should be called
mock_app.persistence_mgr.execute_async.assert_not_called()
class TestCheckDocCapability:
"""Tests for _check_doc_capability method."""
@pytest.mark.asyncio
async def test_passes_when_capability_supported(self):
"""Test that check passes when doc_ingestion capability exists."""
knowledge_module = get_knowledge_service_module()
mock_app = create_mock_app()
mock_app.rag_mgr.get_knowledge_base_details = AsyncMock(
return_value={'knowledge_engine': {'capabilities': ['doc_ingestion']}}
)
service = knowledge_module.KnowledgeService(mock_app)
await service._check_doc_capability('kb1', 'document upload')
# No exception raised means success
@pytest.mark.asyncio
async def test_raises_when_kb_not_found(self):
"""Test that Exception is raised when KB not found."""
knowledge_module = get_knowledge_service_module()
mock_app = create_mock_app()
mock_app.rag_mgr.get_knowledge_base_details = AsyncMock(return_value=None)
service = knowledge_module.KnowledgeService(mock_app)
with pytest.raises(Exception) as exc_info:
await service._check_doc_capability('nonexistent', 'test operation')
assert 'Knowledge base not found' in str(exc_info.value)
@pytest.mark.asyncio
async def test_raises_when_capability_not_supported(self):
"""Test that Exception is raised when doc_ingestion not in capabilities."""
knowledge_module = get_knowledge_service_module()
mock_app = create_mock_app()
mock_app.rag_mgr.get_knowledge_base_details = AsyncMock(
return_value={'knowledge_engine': {'capabilities': ['other_capability']}}
)
service = knowledge_module.KnowledgeService(mock_app)
with pytest.raises(Exception) as exc_info:
await service._check_doc_capability('kb1', 'document upload')
assert 'does not support document upload' in str(exc_info.value)
class TestListKnowledgeEngines:
"""Tests for list_knowledge_engines method."""
@pytest.mark.asyncio
async def test_returns_engines_from_plugin_connector(self):
"""Test that it returns knowledge engines from plugin connector."""
knowledge_module = get_knowledge_service_module()
mock_app = create_mock_app()
mock_app.plugin_connector.list_knowledge_engines = AsyncMock(
return_value=[{'id': 'engine1', 'name': 'Engine 1'}]
)
service = knowledge_module.KnowledgeService(mock_app)
result = await service.list_knowledge_engines()
assert len(result) == 1
assert result[0]['id'] == 'engine1'
@pytest.mark.asyncio
async def test_returns_empty_when_plugin_disabled(self):
"""Test that it returns empty list when plugin disabled."""
knowledge_module = get_knowledge_service_module()
mock_app = create_mock_app()
mock_app.plugin_connector.is_enable_plugin = False
service = knowledge_module.KnowledgeService(mock_app)
result = await service.list_knowledge_engines()
assert result == []
@pytest.mark.asyncio
async def test_returns_empty_on_exception(self):
"""Test that it returns empty list and logs warning on exception."""
knowledge_module = get_knowledge_service_module()
mock_app = create_mock_app()
mock_app.plugin_connector.list_knowledge_engines = AsyncMock(
side_effect=Exception('Connection error')
)
service = knowledge_module.KnowledgeService(mock_app)
result = await service.list_knowledge_engines()
assert result == []
mock_app.logger.warning.assert_called_once()
class TestListParsers:
"""Tests for list_parsers method."""
@pytest.mark.asyncio
async def test_returns_all_parsers(self):
"""Test that it returns all parsers when no MIME type filter."""
knowledge_module = get_knowledge_service_module()
mock_app = create_mock_app()
mock_app.plugin_connector.list_parsers = AsyncMock(
return_value=[
{'id': 'parser1', 'supported_mime_types': ['text/plain']},
{'id': 'parser2', 'supported_mime_types': ['application/pdf']},
]
)
service = knowledge_module.KnowledgeService(mock_app)
result = await service.list_parsers()
assert len(result) == 2
@pytest.mark.asyncio
async def test_filters_by_mime_type(self):
"""Test that it filters parsers by MIME type."""
knowledge_module = get_knowledge_service_module()
mock_app = create_mock_app()
mock_app.plugin_connector.list_parsers = AsyncMock(
return_value=[
{'id': 'parser1', 'supported_mime_types': ['text/plain']},
{'id': 'parser2', 'supported_mime_types': ['application/pdf']},
]
)
service = knowledge_module.KnowledgeService(mock_app)
result = await service.list_parsers(mime_type='application/pdf')
assert len(result) == 1
assert result[0]['id'] == 'parser2'
@pytest.mark.asyncio
async def test_returns_empty_when_plugin_disabled(self):
"""Test that it returns empty list when plugin disabled."""
knowledge_module = get_knowledge_service_module()
mock_app = create_mock_app()
mock_app.plugin_connector.is_enable_plugin = False
service = knowledge_module.KnowledgeService(mock_app)
result = await service.list_parsers()
assert result == []
class TestGetEngineSchemas:
"""Tests for get_engine_creation_schema and get_engine_retrieval_schema."""
@pytest.mark.asyncio
async def test_returns_creation_schema(self):
"""Test that it returns creation schema for engine."""
knowledge_module = get_knowledge_service_module()
mock_app = create_mock_app()
mock_app.plugin_connector.get_rag_creation_schema = AsyncMock(
return_value={'properties': {'name': {'type': 'string'}}}
)
service = knowledge_module.KnowledgeService(mock_app)
result = await service.get_engine_creation_schema('author/engine')
assert 'properties' in result
@pytest.mark.asyncio
async def test_returns_retrieval_schema(self):
"""Test that it returns retrieval schema for engine."""
knowledge_module = get_knowledge_service_module()
mock_app = create_mock_app()
mock_app.plugin_connector.get_rag_retrieval_schema = AsyncMock(
return_value={'properties': {'top_k': {'type': 'integer'}}}
)
service = knowledge_module.KnowledgeService(mock_app)
result = await service.get_engine_retrieval_schema('author/engine')
assert 'properties' in result
@pytest.mark.asyncio
async def test_returns_empty_dict_on_exception(self):
"""Test that it returns empty dict and logs warning on exception."""
knowledge_module = get_knowledge_service_module()
mock_app = create_mock_app()
mock_app.plugin_connector.get_rag_creation_schema = AsyncMock(
side_effect=Exception('Plugin error')
)
service = knowledge_module.KnowledgeService(mock_app)
result = await service.get_engine_creation_schema('author/engine')
assert result == {}
mock_app.logger.warning.assert_called_once()
@@ -0,0 +1,824 @@
"""
Unit tests for MaintenanceService.
Tests storage maintenance and diagnostics including:
- Cleanup expired files
- Storage analysis
- File counting and sizing
- Monitoring counts
- Binary storage stats
Source: src/langbot/pkg/api/http/service/maintenance.py
"""
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock, patch, MagicMock
from types import SimpleNamespace
import datetime
from pathlib import Path
from langbot.pkg.api.http.service.maintenance import MaintenanceService
pytestmark = pytest.mark.asyncio
def _create_mock_result(scalar_value=None):
"""Create mock result object for persistence queries."""
result = Mock()
result.scalar = Mock(return_value=scalar_value)
return result
class TestMaintenanceServiceCleanupExpiredFiles:
"""Tests for cleanup_expired_files method."""
async def test_cleanup_expired_files_default_retention(self):
"""Uses default retention days when config not set."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {}
ap.storage_mgr = SimpleNamespace()
# Create a proper mock object with __class__.__name__
storage_provider = MagicMock()
storage_provider.__class__.__name__ = 'LocalStorageProvider'
ap.storage_mgr.storage_provider = storage_provider
ap.logger = SimpleNamespace()
ap.logger.warning = Mock()
service = MaintenanceService(ap)
# Mock the internal cleanup methods - one is async, one is not
service._cleanup_expired_uploaded_files = AsyncMock(return_value=0)
service._cleanup_expired_log_files = Mock(return_value=0) # NOT async!
# Execute
result = await service.cleanup_expired_files()
# Verify - returns counts
assert 'uploaded_files' in result
assert 'log_files' in result
assert result['uploaded_files'] == 0
assert result['log_files'] == 0
async def test_cleanup_expired_files_custom_retention(self):
"""Uses custom retention days from config."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {
'storage': {
'cleanup': {
'uploaded_file_retention_days': 14,
'log_retention_days': 7,
}
}
}
ap.storage_mgr = SimpleNamespace()
storage_provider = MagicMock()
storage_provider.__class__.__name__ = 'LocalStorageProvider'
ap.storage_mgr.storage_provider = storage_provider
ap.logger = SimpleNamespace()
ap.logger.warning = Mock()
service = MaintenanceService(ap)
# Mock the internal cleanup methods
service._cleanup_expired_uploaded_files = AsyncMock(return_value=2)
service._cleanup_expired_log_files = Mock(return_value=3) # NOT async
# Execute
result = await service.cleanup_expired_files()
# Verify
assert result['uploaded_files'] == 2
assert result['log_files'] == 3
async def test_cleanup_expired_files_s3_provider(self):
"""Handles S3StorageProvider correctly."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {}
ap.storage_mgr = SimpleNamespace()
# Mock S3 provider
s3_provider = MagicMock()
s3_provider.__class__.__name__ = 'S3StorageProvider'
s3_provider.delete = AsyncMock()
ap.storage_mgr.storage_provider = s3_provider
ap.logger = SimpleNamespace()
ap.logger.warning = Mock()
service = MaintenanceService(ap)
# Mock the internal cleanup methods
service._cleanup_expired_uploaded_files = AsyncMock(return_value=1)
service._cleanup_expired_log_files = Mock(return_value=0) # NOT async
# Execute
result = await service.cleanup_expired_files()
# Verify
assert result['uploaded_files'] == 1
assert result['log_files'] == 0
async def test_cleanup_expired_files_invalid_retention(self):
"""Uses default for invalid retention config."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {
'storage': {
'cleanup': {
'uploaded_file_retention_days': 'invalid', # Invalid
'log_retention_days': 0, # Invalid (less than 1)
}
}
}
ap.storage_mgr = SimpleNamespace()
storage_provider = MagicMock()
storage_provider.__class__.__name__ = 'LocalStorageProvider'
ap.storage_mgr.storage_provider = storage_provider
ap.logger = SimpleNamespace()
ap.logger.warning = Mock()
service = MaintenanceService(ap)
# Mock the internal cleanup methods
service._cleanup_expired_uploaded_files = AsyncMock(return_value=0)
service._cleanup_expired_log_files = Mock(return_value=0) # NOT async
# Execute
result = await service.cleanup_expired_files()
# Verify - warning logged, defaults used
assert ap.logger.warning.called
assert 'uploaded_files' in result
class TestMaintenanceServiceGetStorageAnalysis:
"""Tests for get_storage_analysis method."""
async def test_get_storage_analysis_basic(self):
"""Returns basic storage analysis."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {
'database': {'use': 'sqlite', 'sqlite': {'path': 'data/langbot.db'}}
}
ap.persistence_mgr = SimpleNamespace()
ap.logger = SimpleNamespace()
ap.logger.warning = Mock()
ap.task_mgr = SimpleNamespace()
ap.task_mgr.get_stats = Mock(return_value={'running': 0})
# Mock monitoring counts
count_result = _create_mock_result(scalar_value=10)
ap.persistence_mgr.execute_async = AsyncMock(return_value=count_result)
service = MaintenanceService(ap)
# Mock file operations
service._path_size = Mock(return_value=1000)
service._file_count = Mock(return_value=5)
service._monitoring_counts = AsyncMock(return_value={'messages': 10, 'errors': 0})
service._binary_storage_stats = AsyncMock(return_value={'count': 5, 'size_bytes': 500})
service._expired_uploaded_candidates = AsyncMock(return_value=[])
service._expired_log_candidates = Mock(return_value=[])
# Execute
result = await service.get_storage_analysis()
# Verify
assert 'generated_at' in result
assert 'cleanup_policy' in result
assert 'sections' in result
assert 'database' in result
assert 'cleanup_candidates' in result
async def test_get_storage_analysis_sections(self):
"""Returns all storage sections."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'database': {'use': 'postgresql'}}
ap.persistence_mgr = SimpleNamespace()
ap.logger = SimpleNamespace()
ap.logger.warning = Mock()
ap.task_mgr = None
count_result = _create_mock_result(scalar_value=0)
ap.persistence_mgr.execute_async = AsyncMock(return_value=count_result)
service = MaintenanceService(ap)
service._path_size = Mock(return_value=0)
service._file_count = Mock(return_value=0)
service._monitoring_counts = AsyncMock(return_value={})
service._binary_storage_stats = AsyncMock(return_value={'count': 0, 'size_bytes': 0})
service._expired_uploaded_candidates = AsyncMock(return_value=[])
service._expired_log_candidates = Mock(return_value=[])
# Execute
result = await service.get_storage_analysis()
# Verify - all sections present
sections = {s['key'] for s in result['sections']}
assert 'database' in sections
assert 'logs' in sections
assert 'storage' in sections
assert 'vector_store' in sections
assert 'plugins' in sections
assert 'mcp' in sections
assert 'temp' in sections
async def test_get_storage_analysis_postgresql(self):
"""Handles PostgreSQL database type."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'database': {'use': 'postgresql'}}
ap.persistence_mgr = SimpleNamespace()
ap.logger = SimpleNamespace()
ap.logger.warning = Mock()
ap.task_mgr = None
count_result = _create_mock_result(scalar_value=0)
ap.persistence_mgr.execute_async = AsyncMock(return_value=count_result)
service = MaintenanceService(ap)
service._path_size = Mock(return_value=0)
service._file_count = Mock(return_value=0)
service._monitoring_counts = AsyncMock(return_value={})
service._binary_storage_stats = AsyncMock(return_value={'count': 0, 'size_bytes': None})
service._expired_uploaded_candidates = AsyncMock(return_value=[])
service._expired_log_candidates = Mock(return_value=[])
# Execute
result = await service.get_storage_analysis()
# Verify
assert result['database']['type'] == 'postgresql'
async def test_get_storage_analysis_with_cleanup_candidates(self):
"""Returns cleanup candidates in analysis."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {}
ap.persistence_mgr = SimpleNamespace()
ap.logger = SimpleNamespace()
ap.logger.warning = Mock()
ap.task_mgr = None
count_result = _create_mock_result(scalar_value=0)
ap.persistence_mgr.execute_async = AsyncMock(return_value=count_result)
service = MaintenanceService(ap)
service._path_size = Mock(return_value=0)
service._file_count = Mock(return_value=0)
service._monitoring_counts = AsyncMock(return_value={})
service._binary_storage_stats = AsyncMock(return_value={'count': 0, 'size_bytes': 0})
service._expired_uploaded_candidates = AsyncMock(return_value=[
{'key': 'old_file', 'size_bytes': 100}
])
service._expired_log_candidates = Mock(return_value=[
{'name': 'old_log', 'size_bytes': 50}
])
# Execute
result = await service.get_storage_analysis()
# Verify
assert len(result['cleanup_candidates']['uploaded_files']) == 1
assert len(result['cleanup_candidates']['log_files']) == 1
class TestMaintenanceServiceMonitoringCounts:
"""Tests for _monitoring_counts method."""
async def test_monitoring_counts_returns_counts(self):
"""Returns counts for all monitoring tables."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
count_result = _create_mock_result(scalar_value=42)
ap.persistence_mgr.execute_async = AsyncMock(return_value=count_result)
service = MaintenanceService(ap)
# Execute
result = await service._monitoring_counts()
# Verify - all table keys present
assert 'messages' in result
assert 'llm_calls' in result
assert 'embedding_calls' in result
assert 'errors' in result
assert 'sessions' in result
assert 'feedback' in result
async def test_monitoring_counts_zero_results(self):
"""Returns zero counts when tables empty."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
count_result = _create_mock_result(scalar_value=0)
ap.persistence_mgr.execute_async = AsyncMock(return_value=count_result)
service = MaintenanceService(ap)
# Execute
result = await service._monitoring_counts()
# Verify - all zero
assert all(v == 0 for v in result.values())
class TestMaintenanceServiceBinaryStorageStats:
"""Tests for _binary_storage_stats method."""
async def test_binary_storage_stats_returns_stats(self):
"""Returns count and size for binary storage."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.logger = SimpleNamespace()
ap.logger.warning = Mock()
# Mock count result
count_result = _create_mock_result(scalar_value=10)
# Mock size result
size_result = _create_mock_result(scalar_value=5000)
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return count_result
return size_result
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
service = MaintenanceService(ap)
# Execute
result = await service._binary_storage_stats()
# Verify
assert result['count'] == 10
assert result['size_bytes'] == 5000
async def test_binary_storage_stats_size_error(self):
"""Handles error when calculating size."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.logger = SimpleNamespace()
ap.logger.warning = Mock()
count_result = _create_mock_result(scalar_value=5)
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return count_result
raise Exception('Size calculation error')
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
service = MaintenanceService(ap)
# Execute
result = await service._binary_storage_stats()
# Verify - warning logged, size_bytes None or 0
assert ap.logger.warning.called
assert result['count'] == 5
class TestMaintenanceServicePathSize:
"""Tests for _path_size method."""
def test_path_size_nonexistent_path(self):
"""Returns 0 for nonexistent path."""
# Setup
ap = SimpleNamespace()
ap.logger = SimpleNamespace()
service = MaintenanceService(ap)
# Execute
result = service._path_size(Path('/nonexistent/path'))
# Verify
assert result == 0
def test_path_size_single_file(self):
"""Returns size for single file."""
# Setup
ap = SimpleNamespace()
ap.logger = SimpleNamespace()
service = MaintenanceService(ap)
# Mock file
mock_stat = Mock()
mock_stat.st_size = 100
with patch.object(Path, 'exists', return_value=True):
with patch.object(Path, 'is_file', return_value=True):
with patch.object(Path, 'stat', return_value=mock_stat):
result = service._path_size(Path('test.txt'))
# Verify
assert result == 100
def test_path_size_directory(self):
"""Returns total size for directory."""
# Setup
ap = SimpleNamespace()
ap.logger = SimpleNamespace()
service = MaintenanceService(ap)
# Mock os.walk
with patch.object(Path, 'exists', return_value=True):
with patch.object(Path, 'is_file', return_value=False):
with patch('os.walk') as mock_walk:
mock_walk.return_value = [
('/test_dir', [], ['file1.txt', 'file2.txt']),
]
# Mock file stat
mock_stat = Mock()
mock_stat.st_size = 50
with patch.object(Path, 'stat', return_value=mock_stat):
result = service._path_size(Path('/test_dir'))
# Verify - 2 files * 50 bytes
assert result == 100
class TestMaintenanceServiceFileCount:
"""Tests for _file_count method."""
def test_file_count_nonexistent_path(self):
"""Returns 0 for nonexistent path."""
# Setup
ap = SimpleNamespace()
ap.logger = SimpleNamespace()
service = MaintenanceService(ap)
# Execute
result = service._file_count(Path('/nonexistent/path'))
# Verify
assert result == 0
def test_file_count_single_file(self):
"""Returns 1 for single file."""
# Setup
ap = SimpleNamespace()
ap.logger = SimpleNamespace()
service = MaintenanceService(ap)
with patch.object(Path, 'exists', return_value=True):
with patch.object(Path, 'is_file', return_value=True):
result = service._file_count(Path('test.txt'))
# Verify
assert result == 1
def test_file_count_directory(self):
"""Returns file count for directory."""
# Setup
ap = SimpleNamespace()
ap.logger = SimpleNamespace()
service = MaintenanceService(ap)
with patch.object(Path, 'exists', return_value=True):
with patch.object(Path, 'is_file', return_value=False):
with patch('os.walk') as mock_walk:
mock_walk.return_value = [
('/test_dir', [], ['file1.txt', 'file2.txt', 'file3.txt']),
]
result = service._file_count(Path('/test_dir'))
# Verify
assert result == 3
class TestMaintenanceServicePositiveInt:
"""Tests for _positive_int helper method."""
def test_positive_int_valid_value(self):
"""Returns valid positive integer."""
# Setup
ap = SimpleNamespace()
ap.logger = SimpleNamespace()
ap.logger.warning = Mock()
service = MaintenanceService(ap)
# Execute
result = service._positive_int(7, 5, 'test_param')
# Verify
assert result == 7
assert not ap.logger.warning.called
def test_positive_int_invalid_string(self):
"""Returns default for invalid string."""
# Setup
ap = SimpleNamespace()
ap.logger = SimpleNamespace()
ap.logger.warning = Mock()
service = MaintenanceService(ap)
# Execute
result = service._positive_int('invalid', 5, 'test_param')
# Verify
assert result == 5
assert ap.logger.warning.called
def test_positive_int_invalid_none(self):
"""Returns default for None."""
# Setup
ap = SimpleNamespace()
ap.logger = SimpleNamespace()
ap.logger.warning = Mock()
service = MaintenanceService(ap)
# Execute
result = service._positive_int(None, 5, 'test_param')
# Verify
assert result == 5
assert ap.logger.warning.called
def test_positive_int_negative_value(self):
"""Returns default for negative value."""
# Setup
ap = SimpleNamespace()
ap.logger = SimpleNamespace()
ap.logger.warning = Mock()
service = MaintenanceService(ap)
# Execute
result = service._positive_int(-1, 5, 'test_param')
# Verify
assert result == 5
assert ap.logger.warning.called
def test_positive_int_zero_value(self):
"""Returns default for zero value."""
# Setup
ap = SimpleNamespace()
ap.logger = SimpleNamespace()
ap.logger.warning = Mock()
service = MaintenanceService(ap)
# Execute
result = service._positive_int(0, 5, 'test_param')
# Verify
assert result == 5
assert ap.logger.warning.called
class TestMaintenanceServiceIsUploadedFileKey:
"""Tests for _is_uploaded_file_key helper method."""
def test_is_uploaded_file_key_valid(self):
"""Returns True for valid upload file key."""
# Setup
ap = SimpleNamespace()
service = MaintenanceService(ap)
# Execute - simple filename without path
result = service._is_uploaded_file_key('uploaded_file.txt')
# Verify
assert result is True
def test_is_uploaded_file_key_with_path(self):
"""Returns False for key with path separator."""
# Setup
ap = SimpleNamespace()
service = MaintenanceService(ap)
# Execute - key with path
result = service._is_uploaded_file_key('path/to/file.txt')
# Verify
assert result is False
def test_is_uploaded_file_key_plugin_config(self):
"""Returns False for plugin config prefix."""
# Setup
ap = SimpleNamespace()
service = MaintenanceService(ap)
# Execute - plugin config file
result = service._is_uploaded_file_key('plugin_config_some_plugin.json')
# Verify
assert result is False
class TestMaintenanceServiceExpiredLogCandidates:
"""Tests for _expired_log_candidates method."""
def test_expired_log_candidates_nonexistent_dir(self):
"""Returns empty list when logs dir not exists."""
# Setup
ap = SimpleNamespace()
ap.logger = SimpleNamespace()
service = MaintenanceService(ap)
with patch.object(Path, 'exists', return_value=False):
result = service._expired_log_candidates(3)
# Verify
assert result == []
def test_expired_log_candidates_matches_pattern(self):
"""Matches log file pattern correctly."""
# Setup
ap = SimpleNamespace()
ap.logger = SimpleNamespace()
service = MaintenanceService(ap)
# Mock directory with log files
old_date = datetime.date.today() - datetime.timedelta(days=10)
old_log_name = f'langbot-{old_date.isoformat()}.log'
recent_log_name = f'langbot-{datetime.date.today().isoformat()}.log'
mock_entry_old = Mock(spec=Path)
mock_entry_old.is_file = Mock(return_value=True)
mock_entry_old.name = old_log_name
mock_stat = Mock()
mock_stat.st_size = 1000
mock_entry_old.stat = Mock(return_value=mock_stat)
mock_entry_recent = Mock(spec=Path)
mock_entry_recent.is_file = Mock(return_value=True)
mock_entry_recent.name = recent_log_name
mock_stat2 = Mock()
mock_stat2.st_size = 500
mock_entry_recent.stat = Mock(return_value=mock_stat2)
# Non-log file
mock_entry_other = Mock(spec=Path)
mock_entry_other.is_file = Mock(return_value=True)
mock_entry_other.name = 'other_file.txt'
with patch.object(Path, 'exists', return_value=True):
with patch.object(Path, 'iterdir') as mock_iterdir:
mock_iterdir.return_value = [mock_entry_old, mock_entry_recent, mock_entry_other]
result = service._expired_log_candidates(3)
# Verify - only old log included
assert len(result) == 1
assert result[0]['name'] == old_log_name
def test_expired_log_candidates_includes_path(self):
"""Includes path when include_paths=True."""
# Setup
ap = SimpleNamespace()
ap.logger = SimpleNamespace()
service = MaintenanceService(ap)
old_date = datetime.date.today() - datetime.timedelta(days=10)
old_log_name = f'langbot-{old_date.isoformat()}.log'
mock_entry = Mock(spec=Path)
mock_entry.is_file = Mock(return_value=True)
mock_entry.name = old_log_name
mock_entry.__str__ = Mock(return_value='/data/logs/' + old_log_name)
mock_stat = Mock()
mock_stat.st_size = 1000
mock_entry.stat = Mock(return_value=mock_stat)
with patch.object(Path, 'exists', return_value=True):
with patch.object(Path, 'iterdir') as mock_iterdir:
mock_iterdir.return_value = [mock_entry]
result = service._expired_log_candidates(3, include_paths=True)
# Verify - path included
assert 'path' in result[0]
class TestMaintenanceServiceExpiredLocalUploadCandidates:
"""Tests for _expired_local_upload_candidates method."""
def test_expired_local_upload_candidates_nonexistent_dir(self):
"""Returns empty list when storage dir not exists."""
# Setup
ap = SimpleNamespace()
ap.logger = SimpleNamespace()
service = MaintenanceService(ap)
with patch.object(Path, 'exists', return_value=False):
result = service._expired_local_upload_candidates(7)
# Verify
assert result == []
def test_expired_local_upload_candidates_filters_uploaded(self):
"""Only returns uploaded files matching pattern."""
# Setup
ap = SimpleNamespace()
ap.logger = SimpleNamespace()
service = MaintenanceService(ap)
# Mock _is_uploaded_file_key
service._is_uploaded_file_key = Mock(side_effect=lambda key: 'plugin_config_' not in key and '/' not in key)
# Create mock files - one valid, one plugin config
mock_entry_valid = Mock(spec=Path)
mock_entry_valid.is_file = Mock(return_value=True)
mock_entry_valid.name = 'valid_upload.txt'
mock_stat = Mock()
mock_stat.st_size = 100
mock_stat.st_mtime = 0 # Very old
mock_entry_valid.stat = Mock(return_value=mock_stat)
mock_entry_plugin = Mock(spec=Path)
mock_entry_plugin.is_file = Mock(return_value=True)
mock_entry_plugin.name = 'plugin_config_test.json'
mock_stat2 = Mock()
mock_stat2.st_size = 200
mock_stat2.st_mtime = 0
mock_entry_plugin.stat = Mock(return_value=mock_stat2)
with patch.object(Path, 'exists', return_value=True):
with patch.object(Path, 'iterdir') as mock_iterdir:
mock_iterdir.return_value = [mock_entry_valid, mock_entry_plugin]
result = service._expired_local_upload_candidates(7)
# Verify - only valid upload included
assert len(result) == 1
assert result[0]['key'] == 'valid_upload.txt'
def test_expired_local_upload_candidates_includes_path(self):
"""Includes path when include_paths=True."""
# Setup
ap = SimpleNamespace()
ap.logger = SimpleNamespace()
service = MaintenanceService(ap)
service._is_uploaded_file_key = Mock(return_value=True)
mock_entry = Mock(spec=Path)
mock_entry.is_file = Mock(return_value=True)
mock_entry.name = 'old_file.txt'
mock_entry.__str__ = Mock(return_value='/data/storage/old_file.txt')
mock_stat = Mock()
mock_stat.st_size = 100
mock_stat.st_mtime = 0
mock_entry.stat = Mock(return_value=mock_stat)
with patch.object(Path, 'exists', return_value=True):
with patch.object(Path, 'iterdir') as mock_iterdir:
mock_iterdir.return_value = [mock_entry]
result = service._expired_local_upload_candidates(7, include_paths=True)
# Verify - path included
assert 'path' in result[0]
@@ -0,0 +1,648 @@
"""
Unit tests for MCPService.
Tests MCP server CRUD operations including:
- MCP server listing with runtime info
- MCP server creation with limitations
- MCP server update with enable/disable
- MCP server deletion
- MCP server connection testing
Source: src/langbot/pkg/api/http/service/mcp.py
"""
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock, MagicMock
from types import SimpleNamespace
import uuid
from langbot.pkg.api.http.service.mcp import MCPService
from langbot.pkg.entity.persistence.mcp import MCPServer
pytestmark = pytest.mark.asyncio
def _create_mock_mcp_server(
server_uuid: str = None,
name: str = 'Test MCP Server',
enable: bool = True,
mode: str = 'stdio',
extra_args: dict = None,
) -> Mock:
"""Helper to create mock MCPServer entity."""
server = Mock(spec=MCPServer)
server.uuid = server_uuid or str(uuid.uuid4())
server.name = name
server.enable = enable
server.mode = mode
server.extra_args = extra_args or {}
return server
def _create_mock_result(items: list = None, first_item=None):
"""Create mock result object for persistence queries."""
result = Mock()
result.all = Mock(return_value=items or [])
result.first = Mock(return_value=first_item)
return result
class TestMCPServiceGetRuntimeInfo:
"""Tests for get_runtime_info method."""
async def test_get_runtime_info_session_exists(self):
"""Returns runtime info when session exists."""
# Setup
ap = SimpleNamespace()
ap.tool_mgr = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
mock_session = SimpleNamespace()
mock_session.get_runtime_info_dict = Mock(return_value={'status': 'running', 'tools': 5})
ap.tool_mgr.mcp_tool_loader.get_session = Mock(return_value=mock_session)
service = MCPService(ap)
# Execute
result = await service.get_runtime_info('test-server')
# Verify
assert result is not None
assert result['status'] == 'running'
async def test_get_runtime_info_session_not_exists(self):
"""Returns None when session not exists."""
# Setup
ap = SimpleNamespace()
ap.tool_mgr = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader.get_session = Mock(return_value=None)
service = MCPService(ap)
# Execute
result = await service.get_runtime_info('nonexistent-server')
# Verify
assert result is None
class TestMCPServiceGetMCPServers:
"""Tests for get_mcp_servers method."""
async def test_get_mcp_servers_empty_list(self):
"""Returns empty list when no MCP servers exist."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda model_cls, entity: {
'uuid': entity.uuid,
'name': entity.name,
}
)
ap.tool_mgr = None
service = MCPService(ap)
# Execute
result = await service.get_mcp_servers()
# Verify
assert result == []
async def test_get_mcp_servers_returns_serialized_list(self):
"""Returns serialized list of MCP servers."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
server1 = _create_mock_mcp_server(server_uuid='uuid-1', name='Server 1')
server2 = _create_mock_mcp_server(server_uuid='uuid-2', name='Server 2')
mock_result = _create_mock_result([server1, server2])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda model_cls, entity: {
'uuid': entity.uuid,
'name': entity.name,
'enable': entity.enable,
'mode': entity.mode,
}
)
ap.tool_mgr = None
service = MCPService(ap)
# Execute
result = await service.get_mcp_servers()
# Verify
assert len(result) == 2
assert result[0]['name'] == 'Server 1'
assert result[1]['name'] == 'Server 2'
async def test_get_mcp_servers_with_runtime_info(self):
"""Returns MCP servers with runtime info when requested."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
server1 = _create_mock_mcp_server(server_uuid='uuid-1', name='Server 1')
mock_result = _create_mock_result([server1])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda model_cls, entity: {
'uuid': entity.uuid,
'name': entity.name,
}
)
ap.tool_mgr = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader.get_session = Mock(return_value=None)
service = MCPService(ap)
service.get_runtime_info = AsyncMock(return_value={'status': 'connected'})
# Execute
result = await service.get_mcp_servers(contain_runtime_info=True)
# Verify - runtime info included
assert result[0]['runtime_info'] == {'status': 'connected'}
class TestMCPServiceCreateMCPServer:
"""Tests for create_mcp_server method."""
async def test_create_mcp_server_max_extensions_reached_raises(self):
"""Raises ValueError when max_extensions limit reached."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {
'system': {
'limitation': {
'max_extensions': 2
}
}
}
ap.plugin_connector = SimpleNamespace()
ap.plugin_connector.list_plugins = AsyncMock(return_value=[Mock(), Mock()]) # 2 plugins
# Mock get_mcp_servers to return 0 servers (2 plugins already)
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(return_value={})
ap.tool_mgr = None
service = MCPService(ap)
# Execute & Verify - 2 plugins + new server would exceed limit
with pytest.raises(ValueError, match='Maximum number of extensions'):
await service.create_mcp_server({'name': 'New Server'})
async def test_create_mcp_server_no_limit(self):
"""Creates MCP server without limit when max_extensions=-1."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {
'system': {
'limitation': {
'max_extensions': -1 # No limit
}
}
}
ap.tool_mgr = None
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'new-uuid'})
service = MCPService(ap)
# Execute
server_uuid = await service.create_mcp_server({'name': 'New Server'})
# Verify
assert server_uuid is not None
assert len(server_uuid) == 36 # UUID format
async def test_create_mcp_server_loads_server(self):
"""Loads server into tool_mgr when enabled."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'limitation': {'max_extensions': -1}}}
ap.tool_mgr = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader.host_mcp_server = AsyncMock()
ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks = []
# Create mock server entity
server_entity = _create_mock_mcp_server(server_uuid='new-uuid', enable=True)
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return _create_mock_result([]) # Empty list for limit check
elif call_count == 2:
return Mock() # Insert
return _create_mock_result(first_item=server_entity) # Select created
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(
return_value={'uuid': 'new-uuid', 'name': 'New Server', 'enable': True}
)
service = MCPService(ap)
# Execute
await service.create_mcp_server({'name': 'New Server', 'enable': True})
# Verify - host_mcp_server was called
ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_called_once()
async def test_create_mcp_server_disabled_no_load(self):
"""Does not load server when disabled."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'limitation': {'max_extensions': -1}}}
ap.tool_mgr = None
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'new-uuid'})
service = MCPService(ap)
# Execute with enable=False
server_uuid = await service.create_mcp_server({'name': 'New Server', 'enable': False})
# Verify - no tool_mgr load attempt
assert server_uuid is not None
class TestMCPServiceGetMCPServerByName:
"""Tests for get_mcp_server_by_name method."""
async def test_get_mcp_server_by_name_found(self):
"""Returns MCP server when found by name."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
server = _create_mock_mcp_server(name='Found Server')
mock_result = _create_mock_result(first_item=server)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
return_value={
'uuid': 'test-uuid',
'name': 'Found Server',
'runtime_info': None,
}
)
ap.tool_mgr = None
service = MCPService(ap)
service.get_runtime_info = AsyncMock(return_value=None)
# Execute
result = await service.get_mcp_server_by_name('Found Server')
# Verify
assert result is not None
assert result['name'] == 'Found Server'
async def test_get_mcp_server_by_name_not_found(self):
"""Returns None when MCP server not found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result(first_item=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = MCPService(ap)
# Execute
result = await service.get_mcp_server_by_name('Nonexistent Server')
# Verify
assert result is None
class TestMCPServiceUpdateMCPServer:
"""Tests for update_mcp_server method."""
async def test_update_mcp_server_disable_enabled_server(self):
"""Removes server when disabling previously enabled server."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.tool_mgr = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader.sessions = {'Old Server': Mock()}
ap.tool_mgr.mcp_tool_loader.remove_mcp_server = AsyncMock()
old_server = _create_mock_mcp_server(name='Old Server', enable=True)
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return _create_mock_result(first_item=old_server)
return Mock() # Update
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
service = MCPService(ap)
# Execute - disable server
await service.update_mcp_server('test-uuid', {'enable': False})
# Verify - server was removed
ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_called_once()
async def test_update_mcp_server_enable_disabled_server(self):
"""Loads server when enabling previously disabled server."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.tool_mgr = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader.sessions = {}
ap.tool_mgr.mcp_tool_loader.host_mcp_server = AsyncMock()
ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks = []
old_server = _create_mock_mcp_server(name='Old Server', enable=False)
updated_server = _create_mock_mcp_server(name='Old Server', enable=True)
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return _create_mock_result(first_item=old_server)
elif call_count == 2:
return Mock() # Update
return _create_mock_result(first_item=updated_server) # Select updated
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(
return_value={'uuid': 'test-uuid', 'name': 'Old Server', 'enable': True}
)
service = MCPService(ap)
# Execute - enable server
await service.update_mcp_server('test-uuid', {'enable': True})
# Verify - server was loaded
ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_called_once()
async def test_update_mcp_server_update_enabled_server(self):
"""Removes and reloads server when updating enabled server."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.tool_mgr = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader.sessions = {'Old Server': Mock()}
ap.tool_mgr.mcp_tool_loader.remove_mcp_server = AsyncMock()
ap.tool_mgr.mcp_tool_loader.host_mcp_server = AsyncMock()
ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks = []
old_server = _create_mock_mcp_server(name='Old Server', enable=True)
# Mock for: first select -> update -> second select (for updated server)
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
# All selects return the server
return _create_mock_result(first_item=old_server)
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(
return_value={'uuid': 'test-uuid', 'name': 'Old Server', 'enable': True}
)
service = MCPService(ap)
# Execute - update enabled server (keep enabled, update extra_args)
await service.update_mcp_server('test-uuid', {'enable': True, 'extra_args': {'new': 'args'}})
# Verify - remove and reload
ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_called_once_with('Old Server')
ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_called_once()
async def test_update_mcp_server_no_tool_mgr(self):
"""Updates persistence without tool_mgr operations."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
# Set mcp_tool_loader to None, not tool_mgr itself
ap.tool_mgr = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader = None
old_server = _create_mock_mcp_server(name='Server', enable=True)
# Mock execute for select and update
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return _create_mock_result(first_item=old_server)
return Mock() # Update
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
service = MCPService(ap)
# Execute - should not raise
await service.update_mcp_server('test-uuid', {'name': 'New Name'})
# Verify - persistence was called
assert ap.persistence_mgr.execute_async.call_count >= 2
class TestMCPServiceDeleteMCPServer:
"""Tests for delete_mcp_server method."""
async def test_delete_mcp_server_calls_remove_and_delete(self):
"""Calls both persistence delete and tool_mgr remove."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.tool_mgr = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader.sessions = {'Server to Delete': Mock()}
ap.tool_mgr.mcp_tool_loader.remove_mcp_server = AsyncMock()
server = _create_mock_mcp_server(name='Server to Delete')
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return _create_mock_result(first_item=server)
return Mock() # Delete
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
service = MCPService(ap)
# Execute
await service.delete_mcp_server('test-uuid')
# Verify
ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_called_once_with('Server to Delete')
ap.persistence_mgr.execute_async.assert_called()
async def test_delete_mcp_server_not_in_sessions(self):
"""Does not attempt remove if server not in sessions."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.tool_mgr = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader.sessions = {} # Server not in sessions
ap.tool_mgr.mcp_tool_loader.remove_mcp_server = AsyncMock()
server = _create_mock_mcp_server(name='Not in Sessions')
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return _create_mock_result(first_item=server)
return Mock()
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
service = MCPService(ap)
# Execute
await service.delete_mcp_server('test-uuid')
# Verify - remove not called (server not in sessions)
ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_not_called()
async def test_delete_mcp_server_nonexistent_uuid(self):
"""Delete operation completes even for nonexistent UUID."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.tool_mgr = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader.sessions = {}
ap.tool_mgr.mcp_tool_loader.remove_mcp_server = AsyncMock()
# No server found
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return _create_mock_result(first_item=None)
return Mock()
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
service = MCPService(ap)
# Execute - should not raise
await service.delete_mcp_server('nonexistent-uuid')
# Verify - delete was called regardless
ap.persistence_mgr.execute_async.assert_called()
class TestMCPServiceTestMCPServer:
"""Tests for test_mcp_server method."""
async def test_test_mcp_server_existing_server(self):
"""Tests existing MCP server connection."""
# Setup
ap = SimpleNamespace()
ap.tool_mgr = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
from langbot.pkg.provider.tools.loaders.mcp import MCPSessionStatus
mock_session = MagicMock()
mock_session.status = MCPSessionStatus.ERROR
mock_session.start = AsyncMock()
mock_session.refresh = AsyncMock()
ap.tool_mgr.mcp_tool_loader.get_session = Mock(return_value=mock_session)
ap.task_mgr = SimpleNamespace()
ap.task_mgr.create_user_task = Mock(
return_value=SimpleNamespace(id=123)
)
service = MCPService(ap)
# Execute
task_id = await service.test_mcp_server('existing-server', {})
# Verify - returns task ID
assert task_id == 123
async def test_test_mcp_server_not_found_raises(self):
"""Raises ValueError when server not found."""
# Setup
ap = SimpleNamespace()
ap.tool_mgr = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader.get_session = Mock(return_value=None)
service = MCPService(ap)
# Execute & Verify
with pytest.raises(ValueError, match='Server not found'):
await service.test_mcp_server('nonexistent-server', {})
async def test_test_mcp_server_new_server(self):
"""Tests new MCP server with underscore name."""
# Setup
ap = SimpleNamespace()
ap.tool_mgr = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
mock_session = MagicMock()
mock_session.start = AsyncMock()
ap.tool_mgr.mcp_tool_loader.load_mcp_server = AsyncMock(return_value=mock_session)
ap.task_mgr = SimpleNamespace()
ap.task_mgr.create_user_task = Mock(
return_value=SimpleNamespace(id=456)
)
service = MCPService(ap)
# Execute with '_' name (new server)
task_id = await service.test_mcp_server('_', {'name': 'New Server'})
# Verify - load_mcp_server called
ap.tool_mgr.mcp_tool_loader.load_mcp_server.assert_called_once()
assert task_id == 456
@@ -0,0 +1,964 @@
"""
Unit tests for LLMModelsService, EmbeddingModelsService, and RerankModelsService.
Tests model management operations including:
- Model CRUD operations
- Model with provider info
- Provider auto-creation on model create/update
- Runtime model loading/unloading
- Model deletion
Source: src/langbot/pkg/api/http/service/model.py
"""
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock
from types import SimpleNamespace
from langbot.pkg.api.http.service.model import (
LLMModelsService,
EmbeddingModelsService,
RerankModelsService,
_parse_provider_api_keys,
_runtime_model_data,
)
from langbot.pkg.entity.persistence.model import LLMModel, EmbeddingModel, RerankModel, ModelProvider
pytestmark = pytest.mark.asyncio
def _create_mock_llm_model(
model_uuid: str = 'llm-uuid',
name: str = 'Test LLM',
provider_uuid: str = 'provider-uuid',
abilities: list = None,
extra_args: dict = None,
) -> Mock:
"""Helper to create mock LLMModel entity."""
model = Mock(spec=LLMModel)
model.uuid = model_uuid
model.name = name
model.provider_uuid = provider_uuid
model.abilities = abilities or []
model.extra_args = extra_args or {}
return model
def _create_mock_embedding_model(
model_uuid: str = 'embedding-uuid',
name: str = 'Test Embedding',
provider_uuid: str = 'provider-uuid',
) -> Mock:
"""Helper to create mock EmbeddingModel entity."""
model = Mock(spec=EmbeddingModel)
model.uuid = model_uuid
model.name = name
model.provider_uuid = provider_uuid
model.extra_args = {}
return model
def _create_mock_rerank_model(
model_uuid: str = 'rerank-uuid',
name: str = 'Test Rerank',
provider_uuid: str = 'provider-uuid',
) -> Mock:
"""Helper to create mock RerankModel entity."""
model = Mock(spec=RerankModel)
model.uuid = model_uuid
model.name = name
model.provider_uuid = provider_uuid
model.extra_args = {}
return model
def _create_mock_provider(
provider_uuid: str = 'provider-uuid',
name: str = 'Test Provider',
api_keys: list = None,
) -> Mock:
"""Helper to create mock ModelProvider entity."""
provider = Mock(spec=ModelProvider)
provider.uuid = provider_uuid
provider.name = name
provider.requester = 'openai'
provider.base_url = 'https://api.openai.com'
provider.api_keys = api_keys or ['key']
return provider
def _create_mock_result(items: list = None, first_item=None):
"""Create mock result object for persistence queries."""
result = Mock()
result.all = Mock(return_value=items or [])
result.first = Mock(return_value=first_item)
return result
class TestParseProviderApiKeys:
"""Tests for _parse_provider_api_keys helper function."""
def test_parse_valid_json_string(self):
"""Parses valid JSON string to list."""
provider_dict = {'api_keys': '["key1", "key2"]'}
result = _parse_provider_api_keys(provider_dict)
assert result['api_keys'] == ['key1', 'key2']
def test_parse_invalid_json_returns_empty(self):
"""Returns empty list for invalid JSON."""
provider_dict = {'api_keys': 'invalid json'}
result = _parse_provider_api_keys(provider_dict)
assert result['api_keys'] == []
def test_parse_already_list(self):
"""Returns unchanged if already a list."""
provider_dict = {'api_keys': ['key1', 'key2']}
result = _parse_provider_api_keys(provider_dict)
assert result['api_keys'] == ['key1', 'key2']
def test_parse_missing_key(self):
"""Handles missing api_keys key."""
provider_dict = {'name': 'Provider'}
result = _parse_provider_api_keys(provider_dict)
assert 'api_keys' not in result
class TestRuntimeModelData:
"""Tests for _runtime_model_data helper function."""
def test_runtime_data_preserves_uuid(self):
"""Preserves UUID in runtime data."""
update_payload = {'name': 'Updated', 'provider_uuid': 'provider'}
result = _runtime_model_data('model-uuid', update_payload)
assert result['uuid'] == 'model-uuid'
assert result['name'] == 'Updated'
def test_runtime_data_copies_all_fields(self):
"""Copies all fields from payload."""
update_payload = {
'name': 'Model',
'provider_uuid': 'provider',
'abilities': ['vision'],
'extra_args': {'temp': 0.7},
}
result = _runtime_model_data('uuid', update_payload)
assert result['abilities'] == ['vision']
assert result['extra_args'] == {'temp': 0.7}
class TestLLMModelsServiceGetLLMModels:
"""Tests for LLMModelsService.get_llm_models method."""
async def test_get_llm_models_empty_list(self):
"""Returns empty list when no models exist."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([])
mock_provider_result = _create_mock_result([])
call_count = 0
async def mock_execute(query):
return mock_result if call_count == 0 else mock_provider_result
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda model_cls, entity: {
'uuid': entity.uuid,
'name': entity.name,
'provider_uuid': entity.provider_uuid,
}
)
service = LLMModelsService(ap)
# Execute
result = await service.get_llm_models()
# Verify
assert result == []
async def test_get_llm_models_with_provider_info(self):
"""Returns models with provider info."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
model = _create_mock_llm_model()
provider = _create_mock_provider()
mock_model_result = _create_mock_result([model])
mock_provider_result = _create_mock_result([provider])
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
return mock_model_result if call_count == 1 else mock_provider_result
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda model_cls, entity: {
'uuid': entity.uuid,
'name': entity.name,
'provider_uuid': entity.provider_uuid if hasattr(entity, 'provider_uuid') else None,
'api_keys': entity.api_keys if hasattr(entity, 'api_keys') else None,
}
)
service = LLMModelsService(ap)
# Execute
result = await service.get_llm_models()
# Verify
assert len(result) == 1
assert result[0]['name'] == 'Test LLM'
async def test_get_llm_models_hide_secret_keys(self):
"""Hides secret API keys when include_secret=False."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
model = _create_mock_llm_model()
provider = _create_mock_provider(api_keys=['secret-key-1', 'secret-key-2'])
mock_model_result = _create_mock_result([model])
mock_provider_result = _create_mock_result([provider])
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
return mock_model_result if call_count == 1 else mock_provider_result
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda model_cls, entity: {
'uuid': entity.uuid,
'name': entity.name,
'provider_uuid': entity.provider_uuid if hasattr(entity, 'provider_uuid') else None,
'api_keys': entity.api_keys if hasattr(entity, 'api_keys') else None,
}
)
service = LLMModelsService(ap)
# Execute
result = await service.get_llm_models(include_secret=False)
# Verify - keys should be masked
assert result[0]['provider']['api_keys'] == ['***', '***']
class TestLLMModelsServiceGetLLMModel:
"""Tests for LLMModelsService.get_llm_model method."""
async def test_get_llm_model_found(self):
"""Returns model when found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
model = _create_mock_llm_model(model_uuid='found-uuid')
provider = _create_mock_provider()
mock_model_result = _create_mock_result([], first_item=model)
mock_provider_result = _create_mock_result([], first_item=provider)
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
return mock_model_result if call_count == 1 else mock_provider_result
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(
return_value={
'uuid': 'found-uuid',
'name': 'Test LLM',
'provider_uuid': 'provider-uuid',
'provider': {'uuid': 'provider-uuid', 'api_keys': ['key']},
}
)
service = LLMModelsService(ap)
# Execute
result = await service.get_llm_model('found-uuid')
# Verify
assert result is not None
assert result['uuid'] == 'found-uuid'
async def test_get_llm_model_not_found(self):
"""Returns None when model not found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([], first_item=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = LLMModelsService(ap)
# Execute
result = await service.get_llm_model('nonexistent-uuid')
# Verify
assert result is None
class TestLLMModelsServiceGetLLMModelsByProvider:
"""Tests for LLMModelsService.get_llm_models_by_provider method."""
async def test_get_models_by_provider_uuid(self):
"""Returns models for specific provider."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
model1 = _create_mock_llm_model(model_uuid='model-1', provider_uuid='target-provider')
model2 = _create_mock_llm_model(model_uuid='model-2', provider_uuid='target-provider')
mock_result = _create_mock_result([model1, model2])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
return_value={'uuid': 'model-1', 'name': 'Model 1'}
)
service = LLMModelsService(ap)
# Execute
result = await service.get_llm_models_by_provider('target-provider')
# Verify
assert len(result) == 2
class TestLLMModelsServiceCreateLLMModel:
"""Tests for LLMModelsService.create_llm_model method."""
async def test_create_llm_model_generates_uuid(self):
"""Creates LLM model with generated UUID."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.model_mgr.provider_dict = {'provider-uuid': Mock()}
ap.model_mgr.llm_models = []
ap.model_mgr.load_llm_model_with_provider = AsyncMock(return_value=Mock())
ap.pipeline_service = SimpleNamespace()
ap.pipeline_service.update_pipeline = AsyncMock()
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = LLMModelsService(ap)
# Execute
model_uuid = await service.create_llm_model({
'name': 'New LLM',
'provider_uuid': 'provider-uuid',
'abilities': [],
'extra_args': {},
})
# Verify
assert model_uuid is not None
assert len(model_uuid) == 36 # UUID format
async def test_create_llm_model_preserve_uuid(self):
"""Creates LLM model preserving provided UUID."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.model_mgr.provider_dict = {'provider-uuid': Mock()}
ap.model_mgr.llm_models = []
ap.model_mgr.load_llm_model_with_provider = AsyncMock(return_value=Mock())
ap.pipeline_service = SimpleNamespace()
ap.pipeline_service.update_pipeline = AsyncMock()
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = LLMModelsService(ap)
# Execute
model_uuid = await service.create_llm_model({
'uuid': 'preserved-uuid',
'name': 'Preserved UUID Model',
'provider_uuid': 'provider-uuid',
'abilities': [],
'extra_args': {},
}, preserve_uuid=True)
# Verify
assert model_uuid == 'preserved-uuid'
async def test_create_llm_model_provider_not_found_raises_error(self):
"""Raises Exception when provider not found in runtime."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.model_mgr.provider_dict = {} # Empty - no provider
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = LLMModelsService(ap)
# Execute & Verify
with pytest.raises(Exception, match='provider not found'):
await service.create_llm_model({
'name': 'No Provider Model',
'provider_uuid': 'nonexistent-provider',
'abilities': [],
'extra_args': {},
})
async def test_create_llm_model_with_provider_data(self):
"""Creates provider when provider data provided."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.model_mgr.provider_dict = {}
ap.model_mgr.llm_models = []
ap.model_mgr.load_llm_model_with_provider = AsyncMock(return_value=Mock())
ap.provider_service = SimpleNamespace()
ap.provider_service.find_or_create_provider = AsyncMock(return_value='new-provider-uuid')
ap.pipeline_service = SimpleNamespace()
ap.pipeline_service.update_pipeline = AsyncMock()
# Create runtime provider
runtime_provider = Mock()
ap.model_mgr.provider_dict['new-provider-uuid'] = runtime_provider
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = LLMModelsService(ap)
# Execute - with provider data (no UUID)
result_uuid = await service.create_llm_model({
'name': 'Model with New Provider',
'provider': {
'requester': 'openai',
'base_url': 'https://api.openai.com',
'api_keys': ['key'],
},
'abilities': [],
'extra_args': {},
})
# Verify - provider_service was called and UUID generated
ap.provider_service.find_or_create_provider.assert_called_once()
assert result_uuid is not None
class TestLLMModelsServiceUpdateLLMModel:
"""Tests for LLMModelsService.update_llm_model method."""
async def test_update_llm_model_removes_uuid_from_data(self):
"""Removes uuid from update data before persisting."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.model_mgr.provider_dict = {'provider-uuid': Mock()}
ap.model_mgr.llm_models = []
ap.model_mgr.remove_llm_model = AsyncMock()
ap.model_mgr.load_llm_model_with_provider = AsyncMock(return_value=Mock())
ap.persistence_mgr.execute_async = AsyncMock()
service = LLMModelsService(ap)
# Execute
await service.update_llm_model('existing-uuid', {
'uuid': 'should-be-removed',
'name': 'Updated Name',
'provider_uuid': 'provider-uuid',
})
# Verify - remove and load called
ap.model_mgr.remove_llm_model.assert_called_once_with('existing-uuid')
async def test_update_llm_model_provider_not_found_raises_error(self):
"""Raises Exception when provider not found after update."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.model_mgr.provider_dict = {} # Empty
ap.model_mgr.remove_llm_model = AsyncMock()
ap.persistence_mgr.execute_async = AsyncMock()
service = LLMModelsService(ap)
# Execute & Verify
with pytest.raises(Exception, match='provider not found'):
await service.update_llm_model('model-uuid', {
'name': 'Update',
'provider_uuid': 'nonexistent-provider',
})
class TestLLMModelsServiceDeleteLLMModel:
"""Tests for LLMModelsService.delete_llm_model method."""
async def test_delete_llm_model_success(self):
"""Deletes LLM model successfully."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.model_mgr.remove_llm_model = AsyncMock()
ap.persistence_mgr.execute_async = AsyncMock()
service = LLMModelsService(ap)
# Execute
await service.delete_llm_model('delete-uuid')
# Verify
ap.persistence_mgr.execute_async.assert_called_once()
ap.model_mgr.remove_llm_model.assert_called_once_with('delete-uuid')
class TestEmbeddingModelsServiceGetEmbeddingModels:
"""Tests for EmbeddingModelsService.get_embedding_models method."""
async def test_get_embedding_models_empty_list(self):
"""Returns empty list when no models exist."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
return_value={'uuid': 'embedding-uuid', 'name': 'Test'}
)
service = EmbeddingModelsService(ap)
# Execute
result = await service.get_embedding_models()
# Verify
assert result == []
async def test_get_embedding_models_with_provider(self):
"""Returns embedding models with provider info."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
model = _create_mock_embedding_model()
provider = _create_mock_provider()
mock_model_result = _create_mock_result([model])
mock_provider_result = _create_mock_result([provider])
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
return mock_model_result if call_count == 1 else mock_provider_result
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda model_cls, entity: {
'uuid': entity.uuid,
'name': entity.name,
'provider_uuid': getattr(entity, 'provider_uuid', None),
'api_keys': getattr(entity, 'api_keys', ['key']),
}
)
service = EmbeddingModelsService(ap)
# Execute
result = await service.get_embedding_models()
# Verify
assert len(result) == 1
class TestEmbeddingModelsServiceGetEmbeddingModel:
"""Tests for EmbeddingModelsService.get_embedding_model method."""
async def test_get_embedding_model_found(self):
"""Returns embedding model when found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
model = _create_mock_embedding_model(model_uuid='found-embedding')
provider = _create_mock_provider()
mock_model_result = _create_mock_result([], first_item=model)
mock_provider_result = _create_mock_result([], first_item=provider)
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
return mock_model_result if call_count == 1 else mock_provider_result
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(
return_value={
'uuid': 'found-embedding',
'name': 'Found Embedding',
'provider': {'uuid': 'provider-uuid'},
}
)
service = EmbeddingModelsService(ap)
# Execute
result = await service.get_embedding_model('found-embedding')
# Verify
assert result is not None
async def test_get_embedding_model_not_found(self):
"""Returns None when model not found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([], first_item=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = EmbeddingModelsService(ap)
# Execute
result = await service.get_embedding_model('nonexistent-embedding')
# Verify
assert result is None
class TestEmbeddingModelsServiceCreateEmbeddingModel:
"""Tests for EmbeddingModelsService.create_embedding_model method."""
async def test_create_embedding_model_success(self):
"""Creates embedding model successfully."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.model_mgr.provider_dict = {'provider-uuid': Mock()}
ap.model_mgr.embedding_models = []
ap.model_mgr.load_embedding_model_with_provider = AsyncMock(return_value=Mock())
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = EmbeddingModelsService(ap)
# Execute
model_uuid = await service.create_embedding_model({
'name': 'New Embedding',
'provider_uuid': 'provider-uuid',
'extra_args': {},
})
# Verify
assert model_uuid is not None
assert len(model_uuid) == 36
async def test_create_embedding_model_provider_not_found_raises(self):
"""Raises Exception when provider not found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.model_mgr.provider_dict = {} # Empty
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = EmbeddingModelsService(ap)
# Execute & Verify
with pytest.raises(Exception, match='provider not found'):
await service.create_embedding_model({
'name': 'No Provider Embedding',
'provider_uuid': 'nonexistent',
'extra_args': {},
})
class TestEmbeddingModelsServiceDeleteEmbeddingModel:
"""Tests for EmbeddingModelsService.delete_embedding_model method."""
async def test_delete_embedding_model_success(self):
"""Deletes embedding model successfully."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.model_mgr.remove_embedding_model = AsyncMock()
ap.persistence_mgr.execute_async = AsyncMock()
service = EmbeddingModelsService(ap)
# Execute
await service.delete_embedding_model('delete-embedding-uuid')
# Verify
ap.model_mgr.remove_embedding_model.assert_called_once()
class TestRerankModelsServiceGetRerankModels:
"""Tests for RerankModelsService.get_rerank_models method."""
async def test_get_rerank_models_empty_list(self):
"""Returns empty list when no models exist."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = RerankModelsService(ap)
# Execute
result = await service.get_rerank_models()
# Verify
assert result == []
async def test_get_rerank_models_with_provider(self):
"""Returns rerank models with provider info."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
model = _create_mock_rerank_model()
provider = _create_mock_provider()
mock_model_result = _create_mock_result([model])
mock_provider_result = _create_mock_result([provider])
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
return mock_model_result if call_count == 1 else mock_provider_result
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda model_cls, entity: {
'uuid': entity.uuid,
'name': entity.name,
'provider_uuid': getattr(entity, 'provider_uuid', None),
'api_keys': getattr(entity, 'api_keys', ['key']),
}
)
service = RerankModelsService(ap)
# Execute
result = await service.get_rerank_models()
# Verify
assert len(result) == 1
class TestRerankModelsServiceGetRerankModel:
"""Tests for RerankModelsService.get_rerank_model method."""
async def test_get_rerank_model_found(self):
"""Returns rerank model when found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
model = _create_mock_rerank_model(model_uuid='found-rerank')
provider = _create_mock_provider()
mock_model_result = _create_mock_result([], first_item=model)
mock_provider_result = _create_mock_result([], first_item=provider)
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
return mock_model_result if call_count == 1 else mock_provider_result
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(
return_value={
'uuid': 'found-rerank',
'name': 'Found Rerank',
'provider': {'uuid': 'provider-uuid'},
}
)
service = RerankModelsService(ap)
# Execute
result = await service.get_rerank_model('found-rerank')
# Verify
assert result is not None
async def test_get_rerank_model_not_found(self):
"""Returns None when model not found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([], first_item=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = RerankModelsService(ap)
# Execute
result = await service.get_rerank_model('nonexistent-rerank')
# Verify
assert result is None
class TestRerankModelsServiceCreateRerankModel:
"""Tests for RerankModelsService.create_rerank_model method."""
async def test_create_rerank_model_success(self):
"""Creates rerank model successfully."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.model_mgr.provider_dict = {'provider-uuid': Mock()}
ap.model_mgr.rerank_models = []
ap.model_mgr.load_rerank_model_with_provider = AsyncMock(return_value=Mock())
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = RerankModelsService(ap)
# Execute
model_uuid = await service.create_rerank_model({
'name': 'New Rerank',
'provider_uuid': 'provider-uuid',
'extra_args': {},
})
# Verify
assert model_uuid is not None
async def test_create_rerank_model_provider_not_found_raises(self):
"""Raises Exception when provider not found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.model_mgr.provider_dict = {}
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = RerankModelsService(ap)
# Execute & Verify
with pytest.raises(Exception, match='provider not found'):
await service.create_rerank_model({
'name': 'No Provider Rerank',
'provider_uuid': 'nonexistent',
'extra_args': {},
})
class TestRerankModelsServiceDeleteRerankModel:
"""Tests for RerankModelsService.delete_rerank_model method."""
async def test_delete_rerank_model_success(self):
"""Deletes rerank model successfully."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.model_mgr.remove_rerank_model = AsyncMock()
ap.persistence_mgr.execute_async = AsyncMock()
service = RerankModelsService(ap)
# Execute
await service.delete_rerank_model('delete-rerank-uuid')
# Verify
ap.model_mgr.remove_rerank_model.assert_called_once()
class TestEmbeddingModelsServiceGetEmbeddingModelsByProvider:
"""Tests for EmbeddingModelsService.get_embedding_models_by_provider method."""
async def test_get_embedding_models_by_provider_uuid(self):
"""Returns embedding models for specific provider."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
model1 = _create_mock_embedding_model(model_uuid='emb-1', provider_uuid='provider-uuid')
model2 = _create_mock_embedding_model(model_uuid='emb-2', provider_uuid='provider-uuid')
mock_result = _create_mock_result([model1, model2])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
return_value={'uuid': 'emb-1', 'name': 'Embedding 1'}
)
service = EmbeddingModelsService(ap)
# Execute
result = await service.get_embedding_models_by_provider('provider-uuid')
# Verify
assert len(result) == 2
class TestRerankModelsServiceGetRerankModelsByProvider:
"""Tests for RerankModelsService.get_rerank_models_by_provider method."""
async def test_get_rerank_models_by_provider_uuid(self):
"""Returns rerank models for specific provider."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
model1 = _create_mock_rerank_model(model_uuid='rerank-1', provider_uuid='provider-uuid')
model2 = _create_mock_rerank_model(model_uuid='rerank-2', provider_uuid='provider-uuid')
mock_result = _create_mock_result([model1, model2])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
return_value={'uuid': 'rerank-1', 'name': 'Rerank 1'}
)
service = RerankModelsService(ap)
# Execute
result = await service.get_rerank_models_by_provider('provider-uuid')
# Verify
assert len(result) == 2
@@ -0,0 +1,831 @@
"""
Unit tests for PipelineService.
Tests pipeline CRUD operations including:
- Pipeline listing with sorting
- Pipeline creation with default config
- Pipeline update with bot sync
- Pipeline copy functionality
- Extensions preferences management
Source: src/langbot/pkg/api/http/service/pipeline.py
"""
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock, patch, mock_open
from types import SimpleNamespace
import uuid
import json
from langbot.pkg.api.http.service.pipeline import PipelineService, default_stage_order
from langbot.pkg.entity.persistence.pipeline import LegacyPipeline
pytestmark = pytest.mark.asyncio
def _create_mock_pipeline(
pipeline_uuid: str = None,
name: str = 'Test Pipeline',
description: str = 'Test Description',
is_default: bool = False,
stages: list = None,
config: dict = None,
extensions_preferences: dict = None,
) -> Mock:
"""Helper to create mock LegacyPipeline entity."""
pipeline = Mock(spec=LegacyPipeline)
pipeline.uuid = pipeline_uuid or str(uuid.uuid4())
pipeline.name = name
pipeline.description = description
pipeline.emoji = '⚙️'
pipeline.is_default = is_default
pipeline.for_version = '1.0.0'
pipeline.stages = stages or default_stage_order.copy()
pipeline.config = config or {}
pipeline.extensions_preferences = extensions_preferences or {
'enable_all_plugins': True,
'enable_all_mcp_servers': True,
'plugins': [],
'mcp_servers': [],
}
return pipeline
def _create_mock_result(items: list = None, first_item=None):
"""Create mock result object for persistence queries."""
result = Mock()
result.all = Mock(return_value=items or [])
result.first = Mock(return_value=first_item)
return result
class TestPipelineServiceGetPipelineMetadata:
"""Tests for get_pipeline_metadata method."""
async def test_get_pipeline_metadata_returns_list(self):
"""Returns list of pipeline metadata configs."""
# Setup
ap = SimpleNamespace()
ap.pipeline_config_meta_trigger = {'trigger': {}}
ap.pipeline_config_meta_safety = {'safety': {}}
ap.pipeline_config_meta_ai = {'ai': {}}
ap.pipeline_config_meta_output = {'output': {}}
service = PipelineService(ap)
# Execute
result = await service.get_pipeline_metadata()
# Verify
assert len(result) == 4
assert 'trigger' in result[0]
assert 'safety' in result[1]
assert 'ai' in result[2]
assert 'output' in result[3]
class TestPipelineServiceGetPipelines:
"""Tests for get_pipelines method."""
async def test_get_pipelines_empty_list(self):
"""Returns empty list when no pipelines exist."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda model_cls, entity: {
'uuid': entity.uuid,
'name': entity.name,
}
)
service = PipelineService(ap)
# Execute
result = await service.get_pipelines()
# Verify
assert result == []
async def test_get_pipelines_returns_sorted_by_created_at_desc(self):
"""Returns pipelines sorted by created_at descending by default."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
pipeline1 = _create_mock_pipeline(pipeline_uuid='uuid-1', name='Pipeline 1')
pipeline2 = _create_mock_pipeline(pipeline_uuid='uuid-2', name='Pipeline 2')
mock_result = _create_mock_result([pipeline1, pipeline2])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda model_cls, entity: {
'uuid': entity.uuid,
'name': entity.name,
}
)
service = PipelineService(ap)
# Execute
result = await service.get_pipelines()
# Verify
assert len(result) == 2
ap.persistence_mgr.execute_async.assert_called_once()
async def test_get_pipelines_sort_by_updated_at_asc(self):
"""Returns pipelines sorted by updated_at ascending."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(return_value={})
service = PipelineService(ap)
# Execute
await service.get_pipelines(sort_by='updated_at', sort_order='ASC')
# Verify - execute was called with sort parameters
ap.persistence_mgr.execute_async.assert_called_once()
class TestPipelineServiceGetPipeline:
"""Tests for get_pipeline method."""
async def test_get_pipeline_by_uuid_found(self):
"""Returns pipeline when found by UUID."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
pipeline = _create_mock_pipeline(pipeline_uuid='test-uuid', name='Found Pipeline')
mock_result = _create_mock_result(first_item=pipeline)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
return_value={
'uuid': 'test-uuid',
'name': 'Found Pipeline',
'stages': default_stage_order,
}
)
service = PipelineService(ap)
# Execute
result = await service.get_pipeline('test-uuid')
# Verify
assert result is not None
assert result['uuid'] == 'test-uuid'
assert result['name'] == 'Found Pipeline'
async def test_get_pipeline_by_uuid_not_found(self):
"""Returns None when pipeline not found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result(first_item=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = PipelineService(ap)
# Execute
result = await service.get_pipeline('nonexistent-uuid')
# Verify
assert result is None
class TestPipelineServiceCreatePipeline:
"""Tests for create_pipeline method."""
async def test_create_pipeline_max_limit_reached_raises(self):
"""Raises ValueError when max_pipelines limit reached."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {
'system': {
'limitation': {
'max_pipelines': 2
}
}
}
ap.pipeline_mgr = SimpleNamespace()
ap.pipeline_mgr.load_pipeline = AsyncMock()
ap.ver_mgr = SimpleNamespace()
ap.ver_mgr.get_current_version = Mock(return_value='1.0.0')
mock_result = _create_mock_result([_create_mock_pipeline(), _create_mock_pipeline()])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
return_value={'uuid': 'uuid-1', 'name': 'Pipeline 1'}
)
service = PipelineService(ap)
# Execute & Verify
with pytest.raises(ValueError, match='Maximum number of pipelines'):
await service.create_pipeline({'name': 'New Pipeline'})
async def test_create_pipeline_no_limit(self):
"""Creates pipeline without limit when max_pipelines=-1."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'limitation': {'max_pipelines': -1}}}
ap.pipeline_mgr = SimpleNamespace()
ap.pipeline_mgr.load_pipeline = AsyncMock()
ap.ver_mgr = SimpleNamespace()
ap.ver_mgr.get_current_version = Mock(return_value='1.0.0')
service = PipelineService(ap)
# Override get_pipelines to return empty list (no limit check issue)
service.get_pipelines = AsyncMock(return_value=[])
service.get_pipeline = AsyncMock(return_value={'uuid': 'new-uuid', 'name': 'New Pipeline'})
# Mock persistence for insert
ap.persistence_mgr.execute_async = AsyncMock()
ap.persistence_mgr.serialize_model = Mock(
return_value={'uuid': 'new-uuid', 'name': 'New Pipeline'}
)
# Mock the file read for default config - patch at the utils module level
default_config = {'trigger': {}, 'safety': {}, 'ai': {}, 'output': {}}
with patch('builtins.open', mock_open(read_data=json.dumps(default_config))):
with patch('langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'):
bot_uuid = await service.create_pipeline({'name': 'New Pipeline'})
# Verify
assert bot_uuid is not None
assert len(bot_uuid) == 36 # UUID format
async def test_create_pipeline_as_default(self):
"""Creates pipeline with is_default=True."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'limitation': {'max_pipelines': -1}}}
ap.pipeline_mgr = SimpleNamespace()
ap.pipeline_mgr.load_pipeline = AsyncMock()
ap.ver_mgr = SimpleNamespace()
ap.ver_mgr.get_current_version = Mock(return_value='1.0.0')
service = PipelineService(ap)
service.get_pipelines = AsyncMock(return_value=[])
service.get_pipeline = AsyncMock(return_value={'uuid': 'new-uuid', 'name': 'Default Pipeline', 'is_default': True})
ap.persistence_mgr.execute_async = AsyncMock()
ap.persistence_mgr.serialize_model = Mock(
return_value={'uuid': 'new-uuid', 'name': 'Default Pipeline', 'is_default': True}
)
# Mock the file read
default_config = {}
with patch('builtins.open', mock_open(read_data=json.dumps(default_config))):
with patch('langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'):
await service.create_pipeline({'name': 'Default Pipeline'}, default=True)
# Verify - execute was called
ap.persistence_mgr.execute_async.assert_called()
async def test_create_pipeline_sets_default_extensions_preferences(self):
"""Sets default extensions_preferences when not provided."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'limitation': {'max_pipelines': -1}}}
ap.pipeline_mgr = SimpleNamespace()
ap.pipeline_mgr.load_pipeline = AsyncMock()
ap.ver_mgr = SimpleNamespace()
ap.ver_mgr.get_current_version = Mock(return_value='1.0.0')
service = PipelineService(ap)
service.get_pipelines = AsyncMock(return_value=[])
service.get_pipeline = AsyncMock(return_value={
'uuid': 'new-uuid',
'extensions_preferences': {},
})
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': {},
}
)
default_config = {}
with patch('builtins.open', mock_open(read_data=json.dumps(default_config))):
with patch('langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'):
await service.create_pipeline({'name': 'New Pipeline'})
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:
"""Helper class to mock SQLAlchemy result with iterable .all() method."""
def __init__(self, bots_list):
self._bots_list = bots_list
def all(self):
return self._bots_list
def first(self):
return self._bots_list[0] if self._bots_list else None
class TestPipelineServiceUpdatePipeline:
"""Tests for update_pipeline method."""
async def test_update_pipeline_removes_protected_fields(self):
"""Does not persist protected fields from update data."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.pipeline_mgr = SimpleNamespace()
ap.pipeline_mgr.remove_pipeline = AsyncMock()
ap.pipeline_mgr.load_pipeline = AsyncMock()
ap.sess_mgr = SimpleNamespace()
ap.sess_mgr.session_list = []
ap.bot_service = None # No bot_service when not updating name
ap.persistence_mgr.execute_async = AsyncMock()
service = PipelineService(ap)
service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid', 'name': 'Updated'})
# Execute with protected fields - no name change, so no bot sync
pipeline_data = {
'uuid': 'should-be-removed',
'for_version': 'should-be-removed',
'stages': ['should-be-removed'],
'is_default': True,
'description': 'New description', # Not name change, so no bot_service needed
}
await service.update_pipeline('test-uuid', 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."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.pipeline_mgr = SimpleNamespace()
ap.pipeline_mgr.remove_pipeline = AsyncMock()
ap.pipeline_mgr.load_pipeline = AsyncMock()
ap.sess_mgr = SimpleNamespace()
ap.sess_mgr.session_list = []
ap.bot_service = SimpleNamespace()
ap.bot_service.update_bot = AsyncMock()
# Create proper mock Bot entities with uuid attribute
mock_bot1 = Mock()
mock_bot1.uuid = 'bot-uuid-1'
mock_bot2 = Mock()
mock_bot2.uuid = 'bot-uuid-2'
# Create bot list
bot_list = [mock_bot1, mock_bot2]
# Create mock result using helper class
bot_result = _MockResultWithBots(bot_list)
# The order of calls in update_pipeline:
# 1. UPDATE (line 125) - returns Mock (no result needed)
# 2. SELECT bots (line 136) - returns bot_result with .all()
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
# First call is the UPDATE - just return a Mock
return Mock()
elif call_count == 2:
# Second call is the SELECT bots - return proper result
return bot_result
return Mock() # Any additional calls
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(return_value={})
service = PipelineService(ap)
service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid', 'name': 'New Name'})
# Execute with name change
await service.update_pipeline('test-uuid', {'name': 'New Name'})
# Verify - bot_service.update_bot was called for each bot
assert ap.bot_service.update_bot.call_count == 2
async def test_update_pipeline_clears_conversations(self):
"""Clears session conversations using this pipeline."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.pipeline_mgr = SimpleNamespace()
ap.pipeline_mgr.remove_pipeline = AsyncMock()
ap.pipeline_mgr.load_pipeline = AsyncMock()
ap.sess_mgr = SimpleNamespace()
# Mock session with conversation using this pipeline
session = SimpleNamespace()
session.using_conversation = SimpleNamespace()
session.using_conversation.pipeline_uuid = 'test-uuid'
ap.sess_mgr.session_list = [session]
ap.bot_service = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock()
service = PipelineService(ap)
service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid'})
# Execute
await service.update_pipeline('test-uuid', {'description': 'Updated'})
# Verify - conversation was cleared
assert session.using_conversation is None
class TestPipelineServiceDeletePipeline:
"""Tests for delete_pipeline method."""
async def test_delete_pipeline_calls_remove_and_delete(self):
"""Calls both pipeline_mgr.remove_pipeline and persistence delete."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock()
ap.pipeline_mgr = SimpleNamespace()
ap.pipeline_mgr.remove_pipeline = AsyncMock()
service = PipelineService(ap)
# Execute
await service.delete_pipeline('test-uuid')
# Verify
ap.pipeline_mgr.remove_pipeline.assert_called_once_with('test-uuid')
ap.persistence_mgr.execute_async.assert_called_once()
async def test_delete_pipeline_nonexistent_uuid(self):
"""Delete operation completes even for nonexistent UUID."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock()
ap.pipeline_mgr = SimpleNamespace()
ap.pipeline_mgr.remove_pipeline = AsyncMock()
service = PipelineService(ap)
# Execute - should not raise
await service.delete_pipeline('nonexistent-uuid')
# Verify
ap.pipeline_mgr.remove_pipeline.assert_called_once()
class TestPipelineServiceCopyPipeline:
"""Tests for copy_pipeline method."""
async def test_copy_pipeline_max_limit_reached_raises(self):
"""Raises ValueError when max_pipelines limit reached."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {
'system': {
'limitation': {
'max_pipelines': 2
}
}
}
ap.pipeline_mgr = SimpleNamespace()
ap.pipeline_mgr.load_pipeline = AsyncMock()
ap.ver_mgr = SimpleNamespace()
ap.ver_mgr.get_current_version = Mock(return_value='1.0.0')
service = PipelineService(ap)
# Mock get_pipelines to return 2 pipelines
service.get_pipelines = AsyncMock(return_value=[
{'uuid': 'uuid-1', 'name': 'Pipeline 1'},
{'uuid': 'uuid-2', 'name': 'Pipeline 2'},
])
# Execute & Verify
with pytest.raises(ValueError, match='Maximum number of pipelines'):
await service.copy_pipeline('original-uuid')
async def test_copy_pipeline_not_found_raises(self):
"""Raises ValueError when original pipeline not found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'limitation': {'max_pipelines': -1}}}
ap.pipeline_mgr = SimpleNamespace()
ap.ver_mgr = SimpleNamespace()
ap.ver_mgr.get_current_version = Mock(return_value='1.0.0')
service = PipelineService(ap)
service.get_pipelines = AsyncMock(return_value=[]) # No limit check issue
ap.persistence_mgr.execute_async = AsyncMock(
return_value=_create_mock_result(first_item=None) # Original not found
)
ap.persistence_mgr.serialize_model = Mock(return_value={})
# Execute & Verify
with pytest.raises(ValueError, match='Pipeline original-uuid not found'):
await service.copy_pipeline('original-uuid')
async def test_copy_pipeline_creates_copy(self):
"""Creates a copy with (Copy) suffix."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'limitation': {'max_pipelines': -1}}}
ap.pipeline_mgr = SimpleNamespace()
ap.pipeline_mgr.load_pipeline = AsyncMock()
ap.ver_mgr = SimpleNamespace()
ap.ver_mgr.get_current_version = Mock(return_value='1.0.0')
original = _create_mock_pipeline(
pipeline_uuid='original-uuid',
name='Original Pipeline',
description='Original description',
stages=['Stage1', 'Stage2'],
config={'key': 'value'},
extensions_preferences={'enable_all_plugins': False, 'plugins': ['plugin1']},
)
service = PipelineService(ap)
service.get_pipelines = AsyncMock(return_value=[]) # No limit check issue
# Mock persistence - get original, then insert, then get new
ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=original))
ap.persistence_mgr.serialize_model = Mock(
return_value={
'uuid': 'new-copy-uuid',
'name': 'Original Pipeline (Copy)',
}
)
service.get_pipeline = AsyncMock(
return_value={
'uuid': 'new-copy-uuid',
'name': 'Original Pipeline (Copy)',
}
)
# Execute
new_uuid = await service.copy_pipeline('original-uuid')
# Verify
assert new_uuid is not None
assert len(new_uuid) == 36 # UUID format
async def test_copy_pipeline_is_not_default(self):
"""Copy is never set as default."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'limitation': {'max_pipelines': -1}}}
ap.pipeline_mgr = SimpleNamespace()
ap.pipeline_mgr.load_pipeline = AsyncMock()
ap.ver_mgr = SimpleNamespace()
ap.ver_mgr.get_current_version = Mock(return_value='1.0.0')
# Original is default
original = _create_mock_pipeline(
pipeline_uuid='original-uuid',
name='Default Pipeline',
is_default=True,
)
service = PipelineService(ap)
service.get_pipelines = AsyncMock(return_value=[])
ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=original))
ap.persistence_mgr.serialize_model = Mock(
return_value={'uuid': 'copy-uuid', 'is_default': False}
)
service.get_pipeline = AsyncMock(return_value={'uuid': 'copy-uuid', 'is_default': False})
# Execute
await service.copy_pipeline('original-uuid')
# Verify - pipeline_mgr.load_pipeline called (copy created)
ap.pipeline_mgr.load_pipeline.assert_called_once()
class TestPipelineServiceUpdatePipelineExtensions:
"""Tests for update_pipeline_extensions method."""
async def test_update_extensions_pipeline_not_found_raises(self):
"""Raises ValueError when pipeline not found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result(first_item=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = PipelineService(ap)
# Execute & Verify
with pytest.raises(ValueError, match='Pipeline nonexistent-uuid not found'):
await service.update_pipeline_extensions('nonexistent-uuid', [])
async def test_update_extensions_sets_plugins(self):
"""Updates plugins in extensions_preferences."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.pipeline_mgr = SimpleNamespace()
ap.pipeline_mgr.remove_pipeline = AsyncMock()
ap.pipeline_mgr.load_pipeline = AsyncMock()
original_pipeline = _create_mock_pipeline(
extensions_preferences={'enable_all_plugins': True, 'plugins': []}
)
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return _create_mock_result(first_item=original_pipeline)
return Mock()
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(
return_value={
'uuid': 'test-uuid',
'extensions_preferences': {
'enable_all_plugins': False,
'plugins': [{'plugin_uuid': 'plugin-1'}],
}
}
)
service = PipelineService(ap)
service.get_pipeline = AsyncMock(
return_value={
'uuid': 'test-uuid',
'extensions_preferences': {
'enable_all_plugins': False,
'plugins': [{'plugin_uuid': 'plugin-1'}],
}
}
)
# Execute
bound_plugins = [{'plugin_uuid': 'plugin-1'}]
await service.update_pipeline_extensions(
'test-uuid',
bound_plugins=bound_plugins,
enable_all_plugins=False,
)
# Verify
ap.persistence_mgr.execute_async.assert_called()
async def test_update_extensions_sets_mcp_servers(self):
"""Updates MCP servers in extensions_preferences."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.pipeline_mgr = SimpleNamespace()
ap.pipeline_mgr.remove_pipeline = AsyncMock()
ap.pipeline_mgr.load_pipeline = AsyncMock()
original_pipeline = _create_mock_pipeline()
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return _create_mock_result(first_item=original_pipeline)
return Mock()
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(
return_value={
'uuid': 'test-uuid',
'extensions_preferences': {
'enable_all_mcp_servers': False,
'mcp_servers': ['mcp-server-1'],
}
}
)
service = PipelineService(ap)
service.get_pipeline = AsyncMock(
return_value={
'uuid': 'test-uuid',
'extensions_preferences': {'mcp_servers': ['mcp-server-1']},
}
)
# Execute
await service.update_pipeline_extensions(
'test-uuid',
bound_plugins=[],
bound_mcp_servers=['mcp-server-1'],
enable_all_mcp_servers=False,
)
# Verify
ap.persistence_mgr.execute_async.assert_called()
async def test_update_extensions_none_mcp_servers_keeps_existing(self):
"""Does not modify mcp_servers when bound_mcp_servers is None."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.pipeline_mgr = SimpleNamespace()
ap.pipeline_mgr.remove_pipeline = AsyncMock()
ap.pipeline_mgr.load_pipeline = AsyncMock()
original_pipeline = _create_mock_pipeline(
extensions_preferences={
'enable_all_plugins': True,
'enable_all_mcp_servers': True,
'plugins': [],
'mcp_servers': ['existing-server'],
}
)
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return _create_mock_result(first_item=original_pipeline)
return Mock()
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(
return_value={'uuid': 'test-uuid', 'extensions_preferences': {'mcp_servers': ['existing-server']}}
)
service = PipelineService(ap)
service.get_pipeline = AsyncMock(
return_value={'uuid': 'test-uuid', 'extensions_preferences': {'mcp_servers': ['existing-server']}}
)
# Execute - bound_mcp_servers is None (not provided)
await service.update_pipeline_extensions('test-uuid', bound_plugins=[])
# Verify - persistence was called
ap.persistence_mgr.execute_async.assert_called()
class TestDefaultStageOrder:
"""Tests for default_stage_order constant."""
def test_default_stage_order_not_empty(self):
"""Default stage order is not empty."""
assert len(default_stage_order) > 0
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
@@ -0,0 +1,866 @@
"""
Unit tests for ModelProviderService.
Tests model provider management operations including:
- Provider CRUD operations
- Provider model count checking
- Find or create provider logic
- Space model provider API key updates
- Provider model scanning
Source: src/langbot/pkg/api/http/service/provider.py
"""
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock
from types import SimpleNamespace
from langbot.pkg.api.http.service.provider import ModelProviderService
from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel, EmbeddingModel, RerankModel
pytestmark = pytest.mark.asyncio
def _create_mock_provider(
provider_uuid: str = 'test-provider-uuid',
name: str = 'Test Provider',
requester: str = 'openai',
base_url: str = 'https://api.openai.com',
api_keys: list = None,
) -> Mock:
"""Helper to create mock ModelProvider entity."""
provider = Mock(spec=ModelProvider)
provider.uuid = provider_uuid
provider.name = name
provider.requester = requester
provider.base_url = base_url
provider.api_keys = api_keys or ['test-key']
return provider
def _create_mock_llm_model(
model_uuid: str = 'test-llm-uuid',
name: str = 'Test LLM',
provider_uuid: str = 'test-provider-uuid',
) -> Mock:
"""Helper to create mock LLMModel entity."""
model = Mock(spec=LLMModel)
model.uuid = model_uuid
model.name = name
model.provider_uuid = provider_uuid
return model
def _create_mock_result(items: list = None, first_item=None):
"""Create mock result object for persistence queries."""
result = Mock()
result.all = Mock(return_value=items or [])
result.first = Mock(return_value=first_item)
result.scalar = Mock(return_value=len(items) if items else 0)
return result
class TestModelProviderServiceGetProviders:
"""Tests for get_providers method."""
async def test_get_providers_empty_list(self):
"""Returns empty list when no providers exist."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda model_cls, entity: {
'uuid': entity.uuid,
'name': entity.name,
'requester': entity.requester,
'base_url': entity.base_url,
'api_keys': entity.api_keys,
}
)
service = ModelProviderService(ap)
# Execute
result = await service.get_providers()
# Verify
assert result == []
async def test_get_providers_returns_serialized_list(self):
"""Returns serialized list of providers."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
provider1 = _create_mock_provider(provider_uuid='provider-1', name='Provider 1')
provider2 = _create_mock_provider(provider_uuid='provider-2', name='Provider 2')
mock_result = _create_mock_result([provider1, provider2])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda model_cls, entity: {
'uuid': entity.uuid,
'name': entity.name,
'requester': entity.requester,
'base_url': entity.base_url,
'api_keys': entity.api_keys,
}
)
service = ModelProviderService(ap)
# Execute
result = await service.get_providers()
# Verify
assert len(result) == 2
assert result[0]['name'] == 'Provider 1'
assert result[1]['name'] == 'Provider 2'
async def test_get_providers_parse_api_keys_json_string(self):
"""Parses api_keys from JSON string if needed."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
provider = _create_mock_provider(provider_uuid='provider-1', api_keys='["key1", "key2"]')
mock_result = _create_mock_result([provider])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda model_cls, entity: {
'uuid': entity.uuid,
'name': entity.name,
'api_keys': entity.api_keys, # Returns string
}
)
service = ModelProviderService(ap)
# Execute
result = await service.get_providers()
# Verify - api_keys should be parsed from string
assert result[0]['api_keys'] == ['key1', 'key2']
async def test_get_providers_invalid_json_api_keys_returns_empty(self):
"""Returns empty list for invalid JSON api_keys."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
provider = _create_mock_provider(provider_uuid='provider-1', api_keys='invalid-json')
mock_result = _create_mock_result([provider])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda model_cls, entity: {
'uuid': entity.uuid,
'name': entity.name,
'api_keys': entity.api_keys, # Returns invalid string
}
)
service = ModelProviderService(ap)
# Execute
result = await service.get_providers()
# Verify - invalid JSON returns empty list
assert result[0]['api_keys'] == []
class TestModelProviderServiceGetProvider:
"""Tests for get_provider method."""
async def test_get_provider_by_uuid_found(self):
"""Returns provider when found by UUID."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
provider = _create_mock_provider(provider_uuid='found-uuid', name='Found Provider')
mock_result = _create_mock_result([], first_item=provider)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
return_value={
'uuid': 'found-uuid',
'name': 'Found Provider',
'api_keys': ['key'],
}
)
service = ModelProviderService(ap)
# Execute
result = await service.get_provider('found-uuid')
# Verify
assert result is not None
assert result['uuid'] == 'found-uuid'
async def test_get_provider_by_uuid_not_found(self):
"""Returns None when provider not found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([], first_item=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = ModelProviderService(ap)
# Execute
result = await service.get_provider('nonexistent-uuid')
# Verify
assert result is None
class TestModelProviderServiceCreateProvider:
"""Tests for create_provider method."""
async def test_create_provider_generates_uuid(self):
"""Creates provider with generated UUID."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.model_mgr.provider_dict = {}
# Mock load_provider to return runtime provider
runtime_provider = Mock()
runtime_provider.provider_entity = Mock()
runtime_provider.provider_entity.uuid = 'generated-uuid'
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
ap.persistence_mgr.execute_async = AsyncMock()
service = ModelProviderService(ap)
# Execute
provider_uuid = await service.create_provider({
'name': 'New Provider',
'requester': 'openai',
'base_url': 'https://api.openai.com',
'api_keys': ['key'],
})
# Verify - UUID is generated
assert provider_uuid is not None
assert len(provider_uuid) == 36 # UUID format
async def test_create_provider_loads_to_runtime(self):
"""Loads provider to runtime model_mgr."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.model_mgr.provider_dict = {}
runtime_provider = Mock()
runtime_provider.provider_entity = Mock()
runtime_provider.provider_entity.uuid = 'runtime-uuid'
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
ap.persistence_mgr.execute_async = AsyncMock()
service = ModelProviderService(ap)
# Execute
result_uuid = await service.create_provider({
'name': 'Runtime Provider',
'requester': 'openai',
'base_url': 'https://api.openai.com',
'api_keys': ['key'],
})
# Verify - provider added to runtime dict and UUID generated
ap.model_mgr.load_provider.assert_called_once()
assert result_uuid is not None
class TestModelProviderServiceUpdateProvider:
"""Tests for update_provider method."""
async def test_update_provider_removes_uuid_from_data(self):
"""Removes uuid from update data before persisting."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.model_mgr.reload_provider = AsyncMock()
ap.persistence_mgr.execute_async = AsyncMock()
service = ModelProviderService(ap)
# Execute
await service.update_provider('existing-uuid', {
'uuid': 'should-be-removed', # Will be removed
'name': 'Updated Name',
})
# Verify - reload called
ap.model_mgr.reload_provider.assert_called_once_with('existing-uuid')
async def test_update_provider_reloads_runtime(self):
"""Reloads provider in runtime after update."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.model_mgr.reload_provider = AsyncMock()
ap.persistence_mgr.execute_async = AsyncMock()
service = ModelProviderService(ap)
# Execute
await service.update_provider('update-uuid', {'name': 'New Name'})
# Verify
ap.model_mgr.reload_provider.assert_called_once()
class TestModelProviderServiceDeleteProvider:
"""Tests for delete_provider method."""
async def test_delete_provider_with_llm_models_raises_error(self):
"""Raises ValueError when LLM models reference provider."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
# Mock LLM model exists - only return LLM result since that's first check
llm_result = _create_mock_result([], first_item=_create_mock_llm_model())
ap.persistence_mgr.execute_async = AsyncMock(return_value=llm_result)
service = ModelProviderService(ap)
# Execute & Verify
with pytest.raises(ValueError, match='Cannot delete provider: LLM models'):
await service.delete_provider('provider-with-llm')
async def test_delete_provider_with_embedding_models_raises_error(self):
"""Raises ValueError when Embedding models reference provider."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
# Create results for each check type
llm_result = Mock()
llm_result.first = Mock(return_value=None) # No LLM models
embedding_result = Mock()
embedding_result.first = Mock(return_value=Mock(spec=EmbeddingModel)) # Has embedding model
rerank_result = Mock()
rerank_result.first = Mock(return_value=None)
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return llm_result
elif call_count == 2:
return embedding_result
return rerank_result
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
service = ModelProviderService(ap)
# Execute & Verify - should raise embedding error (LLM check passes, embedding check fails)
with pytest.raises(ValueError, match='Cannot delete provider: Embedding models'):
await service.delete_provider('provider-with-embedding')
async def test_delete_provider_with_rerank_models_raises_error(self):
"""Raises ValueError when Rerank models reference provider."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
# Create results for each check type
llm_result = Mock()
llm_result.first = Mock(return_value=None) # No LLM models
embedding_result = Mock()
embedding_result.first = Mock(return_value=None) # No embedding models
rerank_result = Mock()
rerank_result.first = Mock(return_value=Mock(spec=RerankModel)) # Has rerank model
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return llm_result
elif call_count == 2:
return embedding_result
return rerank_result
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
service = ModelProviderService(ap)
# Execute & Verify - should raise rerank error (LLM and embedding checks pass, rerank check fails)
with pytest.raises(ValueError, match='Cannot delete provider: Rerank models'):
await service.delete_provider('provider-with-rerank')
async def test_delete_provider_no_models_success(self):
"""Deletes provider when no models reference it."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.model_mgr.remove_provider = AsyncMock()
# Mock no models reference provider
empty_result = Mock()
empty_result.first = Mock(return_value=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=empty_result)
service = ModelProviderService(ap)
# Execute
await service.delete_provider('provider-no-models')
# Verify - delete and remove called
ap.model_mgr.remove_provider.assert_called_once_with('provider-no-models')
class TestModelProviderServiceGetProviderModelCounts:
"""Tests for get_provider_model_counts method."""
async def test_get_model_counts_returns_correct_counts(self):
"""Returns correct counts for each model type."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
# Mock scalar results for counts
llm_result = Mock()
llm_result.scalar = Mock(return_value=3)
embedding_result = Mock()
embedding_result.scalar = Mock(return_value=2)
rerank_result = Mock()
rerank_result.scalar = Mock(return_value=1)
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return llm_result
elif call_count == 2:
return embedding_result
return rerank_result
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
service = ModelProviderService(ap)
# Execute
result = await service.get_provider_model_counts('provider-uuid')
# Verify
assert result['llm_count'] == 3
assert result['embedding_count'] == 2
assert result['rerank_count'] == 1
async def test_get_model_counts_zero_counts(self):
"""Returns zero counts when no models."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
zero_result = Mock()
zero_result.scalar = Mock(return_value=0)
ap.persistence_mgr.execute_async = AsyncMock(return_value=zero_result)
service = ModelProviderService(ap)
# Execute
result = await service.get_provider_model_counts('empty-provider')
# Verify
assert result['llm_count'] == 0
assert result['embedding_count'] == 0
assert result['rerank_count'] == 0
class TestModelProviderServiceFindOrCreateProvider:
"""Tests for find_or_create_provider method."""
async def test_find_existing_provider_matching_config(self):
"""Returns existing provider UUID when config matches."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
existing_provider = _create_mock_provider(
provider_uuid='existing-uuid',
requester='openai',
base_url='https://api.openai.com',
api_keys=['key1', 'key2'],
)
mock_result = _create_mock_result([existing_provider])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = ModelProviderService(ap)
# Execute
result = await service.find_or_create_provider(
requester='openai',
base_url='https://api.openai.com',
api_keys=['key1', 'key2'], # Same keys (sorted)
)
# Verify - returns existing UUID
assert result == 'existing-uuid'
async def test_find_existing_provider_keys_order_mismatch(self):
"""Returns existing provider when keys match but order differs."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
existing_provider = _create_mock_provider(
provider_uuid='existing-uuid',
requester='openai',
base_url='https://api.openai.com',
api_keys=['key1', 'key2'],
)
mock_result = _create_mock_result([existing_provider])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = ModelProviderService(ap)
# Execute with reversed key order
result = await service.find_or_create_provider(
requester='openai',
base_url='https://api.openai.com',
api_keys=['key2', 'key1'], # Different order, should still match
)
# Verify - returns existing UUID (keys are sorted in comparison)
assert result == 'existing-uuid'
async def test_create_new_provider_no_match(self):
"""Creates new provider when no existing match."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.model_mgr.provider_dict = {}
runtime_provider = Mock()
runtime_provider.provider_entity = Mock()
runtime_provider.provider_entity.uuid = None # Will be set by uuid.uuid4()
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
# Mock no existing providers
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = ModelProviderService(ap)
# Execute
result = await service.find_or_create_provider(
requester='new-requester',
base_url='https://new.api.com',
api_keys=['new-key'],
)
# Verify - creates new provider with valid UUID format
assert result is not None
assert len(result) == 36 # UUID format
# Verify provider was loaded to runtime
ap.model_mgr.load_provider.assert_called_once()
async def test_create_provider_name_from_url_parse(self):
"""Creates provider with name parsed from URL."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.model_mgr.provider_dict = {}
runtime_provider = Mock()
runtime_provider.provider_entity = Mock()
runtime_provider.provider_entity.uuid = 'parsed-url-uuid'
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = ModelProviderService(ap)
# Execute
result_uuid = await service.find_or_create_provider(
requester='custom',
base_url='https://api.example.com/v1',
api_keys=['key'],
)
# Verify - name should be parsed from URL (api.example.com)
ap.model_mgr.load_provider.assert_called_once()
assert result_uuid is not None
class TestModelProviderServiceUpdateSpaceModelProviderApiKeys:
"""Tests for update_space_model_provider_api_keys method."""
async def test_update_space_provider_api_keys(self):
"""Updates Space provider API keys."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.model_mgr.reload_provider = AsyncMock()
ap.persistence_mgr.execute_async = AsyncMock()
service = ModelProviderService(ap)
# Execute
await service.update_space_model_provider_api_keys('space-api-key')
# Verify - update and reload called for Space provider UUID
ap.model_mgr.reload_provider.assert_called_once_with(
'00000000-0000-0000-0000-000000000000'
)
class TestModelProviderServiceScanProviderModels:
"""Tests for scan_provider_models method."""
async def test_scan_provider_not_found_raises_error(self):
"""Raises ValueError when provider not found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([], first_item=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = ModelProviderService(ap)
# Execute & Verify
with pytest.raises(ValueError, match='provider not found'):
await service.scan_provider_models('nonexistent-uuid')
async def test_scan_provider_returns_models_list(self):
"""Returns scanned models list."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.llm_model_service = SimpleNamespace()
ap.embedding_models_service = SimpleNamespace()
provider = _create_mock_provider(provider_uuid='scan-uuid')
mock_result = _create_mock_result([], first_item=provider)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
return_value={
'uuid': 'scan-uuid',
'name': 'Scan Provider',
'requester': 'openai',
'base_url': 'https://api.openai.com',
'api_keys': ['key'],
}
)
# Mock runtime provider with scan capability
runtime_provider = Mock()
runtime_provider.requester = Mock()
runtime_provider.token_mgr = Mock()
runtime_provider.token_mgr.get_token = Mock(return_value='token')
runtime_provider.token_mgr.tokens = ['token']
# Mock scan_models to return models
async def mock_scan_models(token):
return {
'models': [
{'id': 'gpt-4', 'name': 'GPT-4', 'type': 'llm'},
{'id': 'text-embedding', 'name': 'Text Embedding', 'type': 'embedding'},
],
'debug': None,
}
runtime_provider.requester.scan_models = AsyncMock(side_effect=mock_scan_models)
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
# Mock existing model services
ap.llm_model_service.get_llm_models_by_provider = AsyncMock(return_value=[])
ap.embedding_models_service.get_embedding_models_by_provider = AsyncMock(return_value=[])
service = ModelProviderService(ap)
# Execute
result = await service.scan_provider_models('scan-uuid')
# Verify
assert 'models' in result
assert len(result['models']) == 2
async def test_scan_provider_filter_by_model_type(self):
"""Returns filtered models by type."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.llm_model_service = SimpleNamespace()
ap.embedding_models_service = SimpleNamespace()
provider = _create_mock_provider(provider_uuid='filter-uuid')
mock_result = _create_mock_result([], first_item=provider)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
return_value={
'uuid': 'filter-uuid',
'name': 'Filter Provider',
'requester': 'openai',
'base_url': 'https://api.openai.com',
'api_keys': ['key'],
}
)
runtime_provider = Mock()
runtime_provider.requester = Mock()
runtime_provider.token_mgr = Mock()
runtime_provider.token_mgr.get_token = Mock(return_value='token')
runtime_provider.token_mgr.tokens = ['token']
async def mock_scan_models(token):
return {
'models': [
{'id': 'gpt-4', 'name': 'GPT-4', 'type': 'llm'},
{'id': 'text-embedding', 'name': 'Text Embedding', 'type': 'embedding'},
],
'debug': None,
}
runtime_provider.requester.scan_models = AsyncMock(side_effect=mock_scan_models)
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
ap.llm_model_service.get_llm_models_by_provider = AsyncMock(return_value=[])
ap.embedding_models_service.get_embedding_models_by_provider = AsyncMock(return_value=[])
service = ModelProviderService(ap)
# Execute - filter for LLM only
result = await service.scan_provider_models('filter-uuid', model_type='llm')
# Verify - only LLM models returned
assert len(result['models']) == 1
assert result['models'][0]['type'] == 'llm'
async def test_scan_provider_not_implemented_raises_error(self):
"""Raises ValueError when scan not implemented."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
provider = _create_mock_provider(provider_uuid='no-scan-uuid')
mock_result = _create_mock_result([], first_item=provider)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
return_value={
'uuid': 'no-scan-uuid',
'name': 'No Scan Provider',
'requester': 'custom',
'base_url': 'https://custom.api.com',
'api_keys': ['key'],
}
)
runtime_provider = Mock()
runtime_provider.requester = Mock()
runtime_provider.token_mgr = Mock()
runtime_provider.token_mgr.get_token = Mock(return_value='token')
runtime_provider.token_mgr.tokens = ['token']
runtime_provider.requester.scan_models = AsyncMock(
side_effect=NotImplementedError('scan not supported')
)
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
service = ModelProviderService(ap)
# Execute & Verify
with pytest.raises(ValueError, match='current provider does not support model scanning'):
await service.scan_provider_models('no-scan-uuid')
async def test_scan_provider_marks_already_added_models(self):
"""Marks models that are already added."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.llm_model_service = SimpleNamespace()
ap.embedding_models_service = SimpleNamespace()
provider = _create_mock_provider(provider_uuid='already-added-uuid')
mock_result = _create_mock_result([], first_item=provider)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
return_value={
'uuid': 'already-added-uuid',
'name': 'Already Added Provider',
'requester': 'openai',
'base_url': 'https://api.openai.com',
'api_keys': ['key'],
}
)
runtime_provider = Mock()
runtime_provider.requester = Mock()
runtime_provider.token_mgr = Mock()
runtime_provider.token_mgr.get_token = Mock(return_value='token')
runtime_provider.token_mgr.tokens = ['token']
async def mock_scan_models(token):
return {
'models': [
{'id': 'existing-model', 'name': 'Existing Model', 'type': 'llm'},
{'id': 'new-model', 'name': 'New Model', 'type': 'llm'},
],
'debug': None,
}
runtime_provider.requester.scan_models = AsyncMock(side_effect=mock_scan_models)
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
# Mock existing LLM model
ap.llm_model_service.get_llm_models_by_provider = AsyncMock(
return_value=[{'name': 'Existing Model'}]
)
ap.embedding_models_service.get_embedding_models_by_provider = AsyncMock(return_value=[])
service = ModelProviderService(ap)
# Execute
result = await service.scan_provider_models('already-added-uuid')
# Verify - existing model marked as already_added
existing_model = next(m for m in result['models'] if m['name'] == 'Existing Model')
assert existing_model['already_added'] is True
new_model = next(m for m in result['models'] if m['name'] == 'New Model')
assert new_model['already_added'] is False
@@ -0,0 +1,778 @@
"""
Unit tests for SpaceService.
Tests LangBot Space API interactions including:
- OAuth URL generation
- Token exchange and refresh
- User info retrieval
- Credits caching
- Model listing
Source: src/langbot/pkg/api/http/service/space.py
"""
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock, patch, MagicMock
from types import SimpleNamespace
import datetime
import time
from langbot.pkg.api.http.service.space import SpaceService
from langbot.pkg.entity.persistence.user import User
pytestmark = pytest.mark.asyncio
def _create_mock_user(
email: str = 'test@example.com',
account_type: str = 'space',
space_account_uuid: str = 'space-uuid-123',
space_access_token: str = 'access_token_123',
space_refresh_token: str = 'refresh_token_123',
space_access_token_expires_at: datetime.datetime = None,
) -> Mock:
"""Helper to create mock User entity."""
user = Mock(spec=User)
user.user = email
user.account_type = account_type
user.space_account_uuid = space_account_uuid
user.space_access_token = space_access_token
user.space_refresh_token = space_refresh_token
user.space_access_token_expires_at = space_access_token_expires_at
return user
def _create_mock_result(items: list = None, first_item=None):
"""Create mock result object for persistence queries."""
result = Mock()
result.all = Mock(return_value=items or [])
result.first = Mock(return_value=first_item)
return result
class TestSpaceServiceGetOAuthAuthorizeUrl:
"""Tests for get_oauth_authorize_url method."""
def test_get_oauth_authorize_url_basic(self):
"""Returns OAuth URL with redirect_uri."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {
'space': {
'oauth_authorize_url': 'https://space.langbot.app/auth/authorize',
}
}
service = SpaceService(ap)
# Execute
result = service.get_oauth_authorize_url('http://localhost/callback')
# Verify
assert 'redirect_uri=http://localhost/callback' in result
assert 'https://space.langbot.app/auth/authorize' in result
def test_get_oauth_authorize_url_with_state(self):
"""Returns OAuth URL with redirect_uri and state."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {
'space': {
'oauth_authorize_url': 'https://space.langbot.app/auth/authorize',
}
}
service = SpaceService(ap)
# Execute
result = service.get_oauth_authorize_url('http://localhost/callback', state='random_state')
# Verify
assert 'redirect_uri=http://localhost/callback' in result
assert 'state=random_state' in result
def test_get_oauth_authorize_url_default_config(self):
"""Uses default OAuth URL when config not set."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {}
service = SpaceService(ap)
# Execute
result = service.get_oauth_authorize_url('http://localhost/callback')
# Verify - uses default URL
assert 'https://space.langbot.app/auth/authorize' in result
class TestSpaceServiceGetUserByEmail:
"""Tests for _get_user_by_email internal method."""
async def test_get_user_by_email_found(self):
"""Returns user when found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_user = _create_mock_user(email='found@example.com')
mock_result = _create_mock_result([mock_user])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = SpaceService(ap)
# Execute
result = await service._get_user_by_email('found@example.com')
# Verify
assert result is not None
assert result.user == 'found@example.com'
async def test_get_user_by_email_not_found(self):
"""Returns None when user not found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = SpaceService(ap)
# Execute
result = await service._get_user_by_email('notfound@example.com')
# Verify
assert result is None
class TestSpaceServiceEnsureValidToken:
"""Tests for _ensure_valid_token internal method."""
async def test_ensure_valid_token_user_not_found(self):
"""Returns None when user not found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = SpaceService(ap)
# Execute
result = await service._ensure_valid_token('notfound@example.com')
# Verify
assert result is None
async def test_ensure_valid_token_not_space_account(self):
"""Returns None when user is not a space account."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_user = _create_mock_user(email='local@example.com', account_type='local')
mock_result = _create_mock_result([mock_user])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = SpaceService(ap)
# Execute
result = await service._ensure_valid_token('local@example.com')
# Verify
assert result is None
async def test_ensure_valid_token_no_access_token(self):
"""Returns None when user has no access token."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_user = _create_mock_user(space_access_token=None)
mock_result = _create_mock_result([mock_user])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = SpaceService(ap)
# Execute
result = await service._ensure_valid_token('test@example.com')
# Verify
assert result is None
async def test_ensure_valid_token_valid_token(self):
"""Returns valid access token when not expired."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
# Token expires in 1 hour (valid)
mock_user = _create_mock_user(
space_access_token='valid_token',
space_access_token_expires_at=datetime.datetime.now() + datetime.timedelta(hours=1),
)
mock_result = _create_mock_result([mock_user])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = SpaceService(ap)
# Execute
result = await service._ensure_valid_token('test@example.com')
# Verify
assert result == 'valid_token'
async def test_ensure_valid_token_expired_no_refresh(self):
"""Returns None when token expired and no refresh token."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
# Token expired 1 hour ago
mock_user = _create_mock_user(
space_access_token='expired_token',
space_refresh_token=None,
space_access_token_expires_at=datetime.datetime.now() - datetime.timedelta(hours=1),
)
mock_result = _create_mock_result([mock_user])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = SpaceService(ap)
# Execute
result = await service._ensure_valid_token('test@example.com')
# Verify
assert result is None
class TestSpaceServiceGetCredits:
"""Tests for get_credits method."""
async def test_get_credits_no_user(self):
"""Returns None when user not found."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {}
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = SpaceService(ap)
# Execute
result = await service.get_credits('notfound@example.com')
# Verify
assert result is None
async def test_get_credits_returns_cached_value(self):
"""Returns cached credits without API call."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {}
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = SpaceService(ap)
# Pre-populate cache
service._credits_cache = {'cached@example.com': (100, time.time())}
# Execute
result = await service.get_credits('cached@example.com')
# Verify - returns cached value without API call
assert result == 100
async def test_get_credits_cache_expired_refreshes(self):
"""Refreshes expired cache."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {}
ap.persistence_mgr = SimpleNamespace()
mock_user = _create_mock_user(
space_access_token='valid_token',
space_access_token_expires_at=datetime.datetime.now() + datetime.timedelta(hours=1),
)
mock_result = _create_mock_result([mock_user])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = SpaceService(ap)
# Pre-populate expired cache (70 seconds ago, past 60s TTL)
service._credits_cache = {'test@example.com': (50, time.time() - 70)}
# Mock get_user_info to return new credits
service.get_user_info = AsyncMock(return_value={'credits': 200})
# Execute
result = await service.get_credits('test@example.com')
# Verify - cache was refreshed
assert result == 200
assert service._credits_cache['test@example.com'][0] == 200
async def test_get_credits_force_refresh(self):
"""Force refresh ignores cache."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {}
ap.persistence_mgr = SimpleNamespace()
mock_user = _create_mock_user(
space_access_token='valid_token',
space_access_token_expires_at=datetime.datetime.now() + datetime.timedelta(hours=1),
)
mock_result = _create_mock_result([mock_user])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = SpaceService(ap)
# Pre-populate cache
service._credits_cache = {'test@example.com': (100, time.time())}
# Mock get_user_info to return new credits
service.get_user_info = AsyncMock(return_value={'credits': 300})
# Execute with force_refresh=True
result = await service.get_credits('test@example.com', force_refresh=True)
# Verify - fresh value returned
assert result == 300
async def test_get_credits_returns_cached_on_exception(self):
"""Returns cached fallback value when API fails."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {}
ap.persistence_mgr = SimpleNamespace()
mock_user = _create_mock_user(
space_access_token='valid_token',
space_access_token_expires_at=datetime.datetime.now() + datetime.timedelta(hours=1),
)
mock_result = _create_mock_result([mock_user])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = SpaceService(ap)
# Pre-populate expired cache - will try to refresh and fail
service._credits_cache = {'test@example.com': (150, time.time() - 70)}
# Mock get_user_info to raise exception
service.get_user_info = AsyncMock(side_effect=Exception('API Error'))
# Execute - should return cached fallback value (even though expired)
result = await service.get_credits('test@example.com')
# Verify - returns cached fallback value (150) because API failed
assert result == 150
class TestSpaceServiceRefreshToken:
"""Tests for refresh_token method."""
async def test_refresh_token_success(self):
"""Refreshes token successfully."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {}
service = SpaceService(ap)
# Mock HTTP response
mock_response = MagicMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
'code': 0,
'data': {
'access_token': 'new_access_token',
'refresh_token': 'new_refresh_token',
'expires_in': 3600,
}
})
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
mock_session_obj = MagicMock()
mock_session_obj.post = MagicMock(return_value=mock_response)
mock_session.return_value = mock_session_obj
# Use async context manager mock
mock_session_obj.post.return_value.__aenter__ = AsyncMock(return_value=mock_response)
mock_session_obj.post.return_value.__aexit__ = AsyncMock(return_value=None)
# Execute
result = await service.refresh_token('old_refresh_token')
# Verify
assert result['access_token'] == 'new_access_token'
async def test_refresh_token_api_error(self):
"""Raises ValueError on API error."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {}
service = SpaceService(ap)
# Mock HTTP response with error
mock_response = MagicMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
'code': 1,
'msg': 'Invalid refresh token',
})
mock_response.text = AsyncMock(return_value='{"code":1,"msg":"Invalid refresh token"}')
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
mock_session_obj = MagicMock()
mock_session_obj.post = MagicMock(return_value=mock_response)
mock_session.return_value = mock_session_obj
mock_session_obj.post.return_value.__aenter__ = AsyncMock(return_value=mock_response)
mock_session_obj.post.return_value.__aexit__ = AsyncMock(return_value=None)
# Execute & Verify
with pytest.raises(ValueError, match='Failed to refresh token'):
await service.refresh_token('invalid_refresh_token')
async def test_refresh_token_http_error(self):
"""Raises ValueError on HTTP error."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {}
service = SpaceService(ap)
# Mock HTTP response with error status
mock_response = MagicMock()
mock_response.status = 500
mock_response.text = AsyncMock(return_value='Internal Server Error')
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
mock_session_obj = MagicMock()
mock_session_obj.post = MagicMock(return_value=mock_response)
mock_session.return_value = mock_session_obj
mock_session_obj.post.return_value.__aenter__ = AsyncMock(return_value=mock_response)
mock_session_obj.post.return_value.__aexit__ = AsyncMock(return_value=None)
# Execute & Verify
with pytest.raises(ValueError, match='Failed to refresh token'):
await service.refresh_token('refresh_token')
class TestSpaceServiceExchangeOAuthCode:
"""Tests for exchange_oauth_code method."""
async def test_exchange_oauth_code_success(self):
"""Exchanges OAuth code successfully."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {}
service = SpaceService(ap)
# Mock HTTP response
mock_response = MagicMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
'code': 0,
'data': {
'access_token': 'new_access_token',
'refresh_token': 'new_refresh_token',
'expires_in': 3600,
}
})
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
mock_session_obj = MagicMock()
mock_session_obj.post = MagicMock(return_value=mock_response)
mock_session.return_value = mock_session_obj
mock_session_obj.post.return_value.__aenter__ = AsyncMock(return_value=mock_response)
mock_session_obj.post.return_value.__aexit__ = AsyncMock(return_value=None)
# Execute
result = await service.exchange_oauth_code('auth_code')
# Verify
assert result['access_token'] == 'new_access_token'
async def test_exchange_oauth_code_api_error(self):
"""Raises ValueError on API error."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {}
service = SpaceService(ap)
# Mock HTTP response with error
mock_response = MagicMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={'code': 1, 'msg': 'Invalid code'})
mock_response.text = AsyncMock(return_value='{"code":1,"msg":"Invalid code"}')
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
mock_session_obj = MagicMock()
mock_session_obj.post = MagicMock(return_value=mock_response)
mock_session.return_value = mock_session_obj
mock_session_obj.post.return_value.__aenter__ = AsyncMock(return_value=mock_response)
mock_session_obj.post.return_value.__aexit__ = AsyncMock(return_value=None)
# Execute & Verify
with pytest.raises(ValueError, match='Failed to exchange OAuth code'):
await service.exchange_oauth_code('invalid_code')
class TestSpaceServiceGetUserInfoRaw:
"""Tests for get_user_info_raw method."""
async def test_get_user_info_raw_success(self):
"""Gets user info successfully."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {}
service = SpaceService(ap)
# Mock HTTP response
mock_response = MagicMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
'code': 0,
'data': {
'email': 'test@example.com',
'credits': 100,
}
})
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
mock_session_obj = MagicMock()
mock_session_obj.get = MagicMock(return_value=mock_response)
mock_session.return_value = mock_session_obj
mock_session_obj.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
mock_session_obj.get.return_value.__aexit__ = AsyncMock(return_value=None)
# Execute
result = await service.get_user_info_raw('access_token')
# Verify
assert result['email'] == 'test@example.com'
assert result['credits'] == 100
async def test_get_user_info_raw_api_error(self):
"""Raises ValueError on API error."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {}
service = SpaceService(ap)
# Mock HTTP response with error
mock_response = MagicMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={'code': 1, 'msg': 'Unauthorized'})
mock_response.text = AsyncMock(return_value='{"code":1,"msg":"Unauthorized"}')
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
mock_session_obj = MagicMock()
mock_session_obj.get = MagicMock(return_value=mock_response)
mock_session.return_value = mock_session_obj
mock_session_obj.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
mock_session_obj.get.return_value.__aexit__ = AsyncMock(return_value=None)
# Execute & Verify
with pytest.raises(ValueError, match='Failed to get user info'):
await service.get_user_info_raw('invalid_token')
class TestSpaceServiceGetUserInfo:
"""Tests for get_user_info method (with token validation)."""
async def test_get_user_info_no_token(self):
"""Returns None when no valid token."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {}
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = SpaceService(ap)
# Execute
result = await service.get_user_info('notfound@example.com')
# Verify
assert result is None
async def test_get_user_info_with_valid_token(self):
"""Returns user info with valid token."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {}
ap.persistence_mgr = SimpleNamespace()
mock_user = _create_mock_user(
space_access_token='valid_token',
space_access_token_expires_at=datetime.datetime.now() + datetime.timedelta(hours=1),
)
mock_result = _create_mock_result([mock_user])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = SpaceService(ap)
# Mock get_user_info_raw
service.get_user_info_raw = AsyncMock(return_value={'email': 'test@example.com', 'credits': 100})
# Execute
result = await service.get_user_info('test@example.com')
# Verify
assert result['email'] == 'test@example.com'
class TestSpaceServiceGetModels:
"""Tests for get_models method."""
async def test_get_models_success(self):
"""Gets models successfully."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {}
service = SpaceService(ap)
# Mock HTTP response with proper model data matching SpaceModel schema
mock_response = MagicMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
'code': 0,
'data': {
'models': [
{
'uuid': 'uuid-1',
'model_id': 'model-1',
'provider': 'provider-1',
'category': 'chat',
'status': 'active',
},
{
'uuid': 'uuid-2',
'model_id': 'model-2',
'provider': 'provider-2',
'category': 'chat',
'status': 'active',
},
]
}
})
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
mock_session_obj = MagicMock()
mock_session_obj.get = MagicMock(return_value=mock_response)
mock_session.return_value = mock_session_obj
mock_session_obj.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
mock_session_obj.get.return_value.__aexit__ = AsyncMock(return_value=None)
# Execute
result = await service.get_models()
# Verify
assert len(result) == 2
async def test_get_models_api_error(self):
"""Raises ValueError on API error."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {}
service = SpaceService(ap)
# Mock HTTP response with error
mock_response = MagicMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={'code': 1, 'msg': 'Unauthorized'})
mock_response.text = AsyncMock(return_value='{"code":1,"msg":"Unauthorized"}')
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
mock_session_obj = MagicMock()
mock_session_obj.get = MagicMock(return_value=mock_response)
mock_session.return_value = mock_session_obj
mock_session_obj.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
mock_session_obj.get.return_value.__aexit__ = AsyncMock(return_value=None)
# Execute & Verify
with pytest.raises(ValueError, match='Failed to get models'):
await service.get_models()
class TestSpaceServiceCreditsCache:
"""Tests for credits cache behavior."""
def test_credits_cache_initialized(self):
"""Verify _credits_cache is initialized as empty dict."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {}
service = SpaceService(ap)
# Verify
assert hasattr(service, '_credits_cache')
assert service._credits_cache == {}
async def test_credits_cache_updates_on_success(self):
"""Cache updates when get_credits succeeds."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {}
ap.persistence_mgr = SimpleNamespace()
mock_user = _create_mock_user(
space_access_token='valid_token',
space_access_token_expires_at=datetime.datetime.now() + datetime.timedelta(hours=1),
)
mock_result = _create_mock_result([mock_user])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = SpaceService(ap)
# Mock get_user_info
service.get_user_info = AsyncMock(return_value={'credits': 500})
# Execute
result = await service.get_credits('test@example.com')
# Verify - cache updated
assert result == 500
assert 'test@example.com' in service._credits_cache
assert service._credits_cache['test@example.com'][0] == 500
@@ -0,0 +1,608 @@
"""
Unit tests for UserService.
Tests user management operations including:
- User initialization check
- Local user creation and authentication
- JWT token generation and verification
- Password management (reset, change, set)
- Space account management
Source: src/langbot/pkg/api/http/service/user.py
"""
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock
from types import SimpleNamespace
from langbot.pkg.api.http.service.user import UserService
from langbot.pkg.entity.persistence.user import User
from langbot.pkg.entity.errors.account import AccountEmailMismatchError
pytestmark = pytest.mark.asyncio
def _create_mock_user(
email: str = 'test@example.com',
password: str = 'hashed_password',
account_type: str = 'local',
space_account_uuid: str = None,
) -> Mock:
"""Helper to create mock User entity."""
user = Mock(spec=User)
user.user = email
user.password = password
user.account_type = account_type
user.space_account_uuid = space_account_uuid
return user
def _create_mock_result(items: list = None, first_item=None):
"""Create mock result object for persistence queries."""
result = Mock()
result.all = Mock(return_value=items or [])
result.first = Mock(return_value=first_item)
return result
class TestUserServiceIsInitialized:
"""Tests for is_initialized method."""
async def test_is_initialized_returns_true_when_users_exist(self):
"""Returns True when at least one user exists."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_user = _create_mock_user()
mock_result = _create_mock_result([mock_user])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = UserService(ap)
# Execute
result = await service.is_initialized()
# Verify
assert result is True
async def test_is_initialized_returns_false_when_no_users(self):
"""Returns False when no users exist."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = UserService(ap)
# Execute
result = await service.is_initialized()
# Verify
assert result is False
async def test_is_initialized_returns_false_on_none_result(self):
"""Returns False when result is None."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = Mock()
mock_result.all = Mock(return_value=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = UserService(ap)
# Execute
result = await service.is_initialized()
# Verify
assert result is False
class TestUserServiceGetUserByEmail:
"""Tests for get_user_by_email method."""
async def test_get_user_by_email_found(self):
"""Returns user when found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_user = _create_mock_user(email='found@example.com')
mock_result = _create_mock_result([mock_user])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = UserService(ap)
# Execute
result = await service.get_user_by_email('found@example.com')
# Verify
assert result is not None
assert result.user == 'found@example.com'
async def test_get_user_by_email_not_found(self):
"""Returns None when user not found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = UserService(ap)
# Execute
result = await service.get_user_by_email('notfound@example.com')
# Verify
assert result is None
async def test_get_user_by_email_empty_string(self):
"""Handles empty email string."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = UserService(ap)
# Execute
result = await service.get_user_by_email('')
# Verify
assert result is None
class TestUserServiceGetUserBySpaceAccountUuid:
"""Tests for get_user_by_space_account_uuid method."""
async def test_get_user_by_space_uuid_found(self):
"""Returns user when Space UUID found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_user = _create_mock_user(
email='space@example.com',
account_type='space',
space_account_uuid='space-uuid-123',
)
mock_result = _create_mock_result([mock_user])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = UserService(ap)
# Execute
result = await service.get_user_by_space_account_uuid('space-uuid-123')
# Verify
assert result is not None
assert result.space_account_uuid == 'space-uuid-123'
async def test_get_user_by_space_uuid_not_found(self):
"""Returns None when Space UUID not found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = UserService(ap)
# Execute
result = await service.get_user_by_space_account_uuid('nonexistent-uuid')
# Verify
assert result is None
class TestUserServiceAuthenticate:
"""Tests for authenticate method."""
async def test_authenticate_user_not_found_raises_error(self):
"""Raises ValueError when user not found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': 3600}}}
service = UserService(ap)
# Execute & Verify
with pytest.raises(ValueError, match='用户不存在'):
await service.authenticate('nonexistent@example.com', 'password')
async def test_authenticate_space_user_without_password_raises_error(self):
"""Raises ValueError for Space user without local password."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
# Space user has empty password
mock_user = _create_mock_user(
email='space@example.com',
password='', # Empty password for Space user
account_type='space',
)
mock_result = _create_mock_result([mock_user])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = UserService(ap)
# Execute & Verify
with pytest.raises(ValueError, match='请使用 Space 账户登录'):
await service.authenticate('space@example.com', 'password')
class TestUserServiceGenerateJwtToken:
"""Tests for generate_jwt_token method."""
async def test_generate_jwt_token_returns_valid_token(self):
"""Generates valid JWT token."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': 3600}}}
service = UserService(ap)
# Execute
token = await service.generate_jwt_token('test@example.com')
# Verify - JWT format (base64 encoded parts)
assert token is not None
assert len(token) > 0
parts = token.split('.')
assert len(parts) == 3 # JWT has 3 parts
async def test_generate_jwt_token_custom_expire(self):
"""Generates token with custom expiry."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': 7200}}}
service = UserService(ap)
# Execute
token = await service.generate_jwt_token('test@example.com')
# Verify
assert token is not None
class TestUserServiceVerifyJwtToken:
"""Tests for verify_jwt_token method."""
async def test_verify_jwt_token_valid(self):
"""Verifies valid JWT token and returns user email."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': 3600}}}
service = UserService(ap)
# First generate a valid token
token = await service.generate_jwt_token('verify@example.com')
# Execute
user_email = await service.verify_jwt_token(token)
# Verify
assert user_email == 'verify@example.com'
async def test_verify_jwt_token_invalid_raises_error(self):
"""Raises error for invalid JWT token."""
# Setup
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': 3600}}}
service = UserService(ap)
# Execute & Verify - invalid token should raise JWT error
with pytest.raises(Exception): # jwt.DecodeError or similar
await service.verify_jwt_token('invalid.token.here')
class TestUserServiceResetPassword:
"""Tests for reset_password method."""
async def test_reset_password_updates_password(self):
"""Updates user password."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock()
service = UserService(ap)
# Execute
await service.reset_password('test@example.com', 'new_password')
# Verify - execute_async was called with update
ap.persistence_mgr.execute_async.assert_called_once()
class TestUserServiceChangePassword:
"""Tests for change_password method."""
async def test_change_password_user_not_found_raises_error(self):
"""Raises ValueError when user not found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
service = UserService(ap)
# Mock get_user_by_email to return None
service.get_user_by_email = AsyncMock(return_value=None)
# Execute & Verify
with pytest.raises(ValueError, match='User not found'):
await service.change_password('nonexistent@example.com', 'current', 'new')
async def test_change_password_no_local_password_raises_error(self):
"""Raises ValueError when user has no local password set."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
service = UserService(ap)
# Mock user without password
mock_user = _create_mock_user(email='nopass@example.com', password=None)
service.get_user_by_email = AsyncMock(return_value=mock_user)
# Execute & Verify
with pytest.raises(ValueError, match='No local password set'):
await service.change_password('nopass@example.com', 'current', 'new')
class TestUserServiceGetFirstUser:
"""Tests for get_first_user method."""
async def test_get_first_user_found(self):
"""Returns first user when exists."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_user = _create_mock_user(email='first@example.com')
mock_result = _create_mock_result([mock_user])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = UserService(ap)
# Execute
result = await service.get_first_user()
# Verify
assert result is not None
assert result.user == 'first@example.com'
async def test_get_first_user_not_found(self):
"""Returns None when no users exist."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = UserService(ap)
# Execute
result = await service.get_first_user()
# Verify
assert result is None
class TestUserServiceSetPassword:
"""Tests for set_password method."""
async def test_set_password_user_not_found_raises_error(self):
"""Raises ValueError when user not found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
service = UserService(ap)
# Mock get_user_by_email to return None
service.get_user_by_email = AsyncMock(return_value=None)
# Execute & Verify
with pytest.raises(ValueError, match='User not found'):
await service.set_password('nonexistent@example.com', 'new_password')
async def test_set_password_with_existing_password_requires_current(self):
"""Requires current password when user has existing password."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
service = UserService(ap)
# Mock user with existing password
mock_user = _create_mock_user(email='haspass@example.com', password='hashed_old_password')
service.get_user_by_email = AsyncMock(return_value=mock_user)
# Execute & Verify - should raise when no current_password provided
with pytest.raises(ValueError, match='Current password is required'):
await service.set_password('haspass@example.com', 'new_password')
class TestUserServiceCreateOrUpdateSpaceUser:
"""Tests for create_or_update_space_user method."""
async def test_create_or_update_existing_space_user(self):
"""Updates existing Space user tokens."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.provider_service = SimpleNamespace()
ap.provider_service.update_space_model_provider_api_keys = AsyncMock()
service = UserService(ap)
# Mock existing Space user
existing_user = _create_mock_user(
email='space@example.com',
account_type='space',
space_account_uuid='existing-space-uuid',
)
service.get_user_by_space_account_uuid = AsyncMock(return_value=existing_user)
service.get_user_by_email = AsyncMock(return_value=None)
service.is_initialized = AsyncMock(return_value=True)
ap.persistence_mgr.execute_async = AsyncMock()
# Execute
updated_user = await service.create_or_update_space_user(
space_account_uuid='existing-space-uuid',
email='space@example.com',
access_token='new_access_token',
refresh_token='new_refresh_token',
api_key='new_api_key',
expires_in=3600,
)
# Verify - update was called and user returned
ap.persistence_mgr.execute_async.assert_called()
assert updated_user.space_account_uuid == 'existing-space-uuid'
async def test_create_or_update_new_space_user_first_init(self):
"""Creates new Space user on first initialization."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.provider_service = SimpleNamespace()
ap.provider_service.update_space_model_provider_api_keys = AsyncMock()
service = UserService(ap)
# Mock new user to be returned after creation
new_user = _create_mock_user(
email='newspace@example.com',
account_type='space',
space_account_uuid='new-space-uuid',
)
# First call (line 138) returns None, second call (line 194) returns new_user
call_count = 0
async def mock_get_by_space_uuid(uuid):
nonlocal call_count
call_count += 1
if call_count == 1: # First check for existing user
return None
return new_user # After insert, return the new user
service.get_user_by_space_account_uuid = AsyncMock(side_effect=mock_get_by_space_uuid)
service.get_user_by_email = AsyncMock(return_value=None)
service.is_initialized = AsyncMock(return_value=False) # Not initialized
ap.persistence_mgr.execute_async = AsyncMock()
# Execute
result = await service.create_or_update_space_user(
space_account_uuid='new-space-uuid',
email='newspace@example.com',
access_token='access_token',
refresh_token='refresh_token',
api_key='api_key',
expires_in=3600,
)
# Verify
assert result.space_account_uuid == 'new-space-uuid'
async def test_create_or_update_space_user_already_initialized_raises_error(self):
"""Raises AccountEmailMismatchError when system already initialized and user not found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.provider_service = SimpleNamespace()
ap.provider_service.update_space_model_provider_api_keys = AsyncMock()
service = UserService(ap)
# Mock system already initialized, no matching users
service.get_user_by_space_account_uuid = AsyncMock(return_value=None)
service.get_user_by_email = AsyncMock(return_value=None)
service.is_initialized = AsyncMock(return_value=True) # Already initialized
# Execute & Verify
with pytest.raises(AccountEmailMismatchError):
await service.create_or_update_space_user(
space_account_uuid='unknown-space-uuid',
email='unknown@example.com',
access_token='token',
refresh_token='refresh',
api_key='key',
expires_in=3600,
)
async def test_create_or_update_space_user_no_expiry(self):
"""Creates Space user without token expiry."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.provider_service = SimpleNamespace()
ap.provider_service.update_space_model_provider_api_keys = AsyncMock()
service = UserService(ap)
new_user = _create_mock_user(
email='noexpiry@example.com',
account_type='space',
space_account_uuid='noexpiry-uuid',
)
# First call (line 138) returns None, second call (line 194) returns new_user
call_count = 0
async def mock_get_by_space_uuid(uuid):
nonlocal call_count
call_count += 1
if call_count == 1: # First check for existing user
return None
return new_user # After insert, return the new user
service.get_user_by_space_account_uuid = AsyncMock(side_effect=mock_get_by_space_uuid)
service.get_user_by_email = AsyncMock(return_value=None)
service.is_initialized = AsyncMock(return_value=False)
ap.persistence_mgr.execute_async = AsyncMock()
# Execute with expires_in=0 (no expiry)
result = await service.create_or_update_space_user(
space_account_uuid='noexpiry-uuid',
email='noexpiry@example.com',
access_token='token',
refresh_token='refresh',
api_key='key',
expires_in=0, # No expiry
)
# Verify
assert result is not None
assert result.space_account_uuid == 'noexpiry-uuid'
class TestUserServiceCreateUserLock:
"""Tests for create_user_lock attribute."""
def test_create_user_lock_initialized(self):
"""Verify create_user_lock is initialized as asyncio.Lock."""
# Setup
ap = SimpleNamespace()
service = UserService(ap)
# Verify lock exists
assert hasattr(service, '_create_user_lock')
assert service._create_user_lock is not None
@@ -0,0 +1,506 @@
"""
Unit tests for WebhookService.
Tests webhook CRUD operations including:
- Webhook listing
- Webhook creation
- Webhook retrieval by ID
- Webhook updates
- Webhook deletion
- Enabled webhooks filtering
Source: src/langbot/pkg/api/http/service/webhook.py
"""
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock
from types import SimpleNamespace
from langbot.pkg.api.http.service.webhook import WebhookService
from langbot.pkg.entity.persistence.webhook import Webhook
pytestmark = pytest.mark.asyncio
def _create_mock_webhook(
webhook_id: int = 1,
name: str = 'Test Webhook',
url: str = 'http://example.com/webhook',
description: str = 'Test Description',
enabled: bool = True,
) -> Mock:
"""Helper to create mock Webhook entity."""
webhook = Mock(spec=Webhook)
webhook.id = webhook_id
webhook.name = name
webhook.url = url
webhook.description = description
webhook.enabled = enabled
return webhook
def _create_mock_result(items: list = None, first_item=None):
"""Create mock result object for persistence queries."""
result = Mock()
result.all = Mock(return_value=items or [])
result.first = Mock(return_value=first_item)
return result
class TestWebhookServiceGetWebhooks:
"""Tests for get_webhooks method."""
async def test_get_webhooks_empty_list(self):
"""Returns empty list when no webhooks exist."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda model_cls, entity: {
'id': entity.id,
'name': entity.name,
'url': entity.url,
}
)
service = WebhookService(ap)
# Execute
result = await service.get_webhooks()
# Verify
assert result == []
async def test_get_webhooks_returns_serialized_list(self):
"""Returns serialized list of webhooks."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
webhook1 = _create_mock_webhook(webhook_id=1, name='Webhook 1')
webhook2 = _create_mock_webhook(webhook_id=2, name='Webhook 2')
mock_result = _create_mock_result([webhook1, webhook2])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda model_cls, entity: {
'id': entity.id,
'name': entity.name,
'url': entity.url,
'description': entity.description,
'enabled': entity.enabled,
}
)
service = WebhookService(ap)
# Execute
result = await service.get_webhooks()
# Verify
assert len(result) == 2
assert result[0]['name'] == 'Webhook 1'
assert result[1]['name'] == 'Webhook 2'
class TestWebhookServiceCreateWebhook:
"""Tests for create_webhook method."""
async def test_create_webhook_full_params(self):
"""Creates webhook with all parameters."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
# Mock insert result
insert_result = Mock()
# Mock select result for retrieving created webhook
created_webhook = _create_mock_webhook(
webhook_id=1,
name='New Webhook',
url='http://new.example.com/webhook',
description='New Description',
enabled=True,
)
select_result = _create_mock_result(first_item=created_webhook)
# execute_async returns different results
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return insert_result # Insert
return select_result # Select
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(
return_value={
'id': 1,
'name': 'New Webhook',
'url': 'http://new.example.com/webhook',
'description': 'New Description',
'enabled': True,
}
)
service = WebhookService(ap)
# Execute
result = await service.create_webhook(
name='New Webhook',
url='http://new.example.com/webhook',
description='New Description',
enabled=True,
)
# Verify
assert result['name'] == 'New Webhook'
assert result['url'] == 'http://new.example.com/webhook'
assert result['description'] == 'New Description'
assert result['enabled'] is True
async def test_create_webhook_defaults(self):
"""Creates webhook with default description and enabled."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
created_webhook = _create_mock_webhook(
webhook_id=1,
name='Minimal Webhook',
url='http://minimal.example.com',
description='', # Default
enabled=True, # Default
)
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return Mock() # Insert
return _create_mock_result(first_item=created_webhook)
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(
return_value={
'id': 1,
'name': 'Minimal Webhook',
'url': 'http://minimal.example.com',
'description': '',
'enabled': True,
}
)
service = WebhookService(ap)
# Execute - only name and url required
result = await service.create_webhook(name='Minimal Webhook', url='http://minimal.example.com')
# Verify defaults
assert result['description'] == ''
assert result['enabled'] is True
async def test_create_webhook_disabled(self):
"""Creates webhook with enabled=False."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
created_webhook = _create_mock_webhook(webhook_id=1, enabled=False)
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return Mock()
return _create_mock_result(first_item=created_webhook)
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(
return_value={'id': 1, 'enabled': False}
)
service = WebhookService(ap)
# Execute
result = await service.create_webhook(name='Disabled', url='http://disabled.com', enabled=False)
# Verify
assert result['enabled'] is False
class TestWebhookServiceGetWebhook:
"""Tests for get_webhook method."""
async def test_get_webhook_by_id_found(self):
"""Returns webhook when found by ID."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
webhook = _create_mock_webhook(webhook_id=1, name='Found Webhook')
mock_result = _create_mock_result(first_item=webhook)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
return_value={
'id': 1,
'name': 'Found Webhook',
'url': 'http://example.com/webhook',
}
)
service = WebhookService(ap)
# Execute
result = await service.get_webhook(1)
# Verify
assert result is not None
assert result['id'] == 1
assert result['name'] == 'Found Webhook'
async def test_get_webhook_by_id_not_found(self):
"""Returns None when webhook not found."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result(first_item=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = WebhookService(ap)
# Execute
result = await service.get_webhook(999)
# Verify
assert result is None
async def test_get_webhook_by_id_zero(self):
"""Handles ID=0 (edge case) correctly."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result(first_item=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
service = WebhookService(ap)
# Execute
result = await service.get_webhook(0)
# Verify - should return None (no webhook with ID 0)
assert result is None
class TestWebhookServiceUpdateWebhook:
"""Tests for update_webhook method."""
async def test_update_webhook_name_only(self):
"""Updates only the name field."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock()
service = WebhookService(ap)
# Execute
await service.update_webhook(1, name='Updated Name')
# Verify
ap.persistence_mgr.execute_async.assert_called_once()
async def test_update_webhook_url_only(self):
"""Updates only the url field."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock()
service = WebhookService(ap)
# Execute
await service.update_webhook(1, url='http://updated.example.com')
# Verify
ap.persistence_mgr.execute_async.assert_called_once()
async def test_update_webhook_description_only(self):
"""Updates only the description field."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock()
service = WebhookService(ap)
# Execute
await service.update_webhook(1, description='Updated description')
# Verify
ap.persistence_mgr.execute_async.assert_called_once()
async def test_update_webhook_enabled_only(self):
"""Updates only the enabled field."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock()
service = WebhookService(ap)
# Execute
await service.update_webhook(1, enabled=False)
# Verify
ap.persistence_mgr.execute_async.assert_called_once()
async def test_update_webhook_all_fields(self):
"""Updates all fields at once."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock()
service = WebhookService(ap)
# Execute
await service.update_webhook(
1,
name='All Updated',
url='http://all.updated.com',
description='All updated description',
enabled=False,
)
# Verify
ap.persistence_mgr.execute_async.assert_called_once()
async def test_update_webhook_no_fields(self):
"""Does nothing when no fields provided."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock()
service = WebhookService(ap)
# Execute - no update parameters
await service.update_webhook(1)
# Verify - no execute call since no update_data
ap.persistence_mgr.execute_async.assert_not_called()
class TestWebhookServiceDeleteWebhook:
"""Tests for delete_webhook method."""
async def test_delete_webhook_by_id(self):
"""Deletes webhook by ID."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock()
service = WebhookService(ap)
# Execute
await service.delete_webhook(1)
# Verify
ap.persistence_mgr.execute_async.assert_called_once()
async def test_delete_webhook_nonexistent_id(self):
"""Delete operation completes even for nonexistent ID."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock()
service = WebhookService(ap)
# Execute - should not raise
await service.delete_webhook(999)
# Verify - still called
ap.persistence_mgr.execute_async.assert_called_once()
class TestWebhookServiceGetEnabledWebhooks:
"""Tests for get_enabled_webhooks method."""
async def test_get_enabled_webhooks_empty(self):
"""Returns empty list when no enabled webhooks."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(return_value={})
service = WebhookService(ap)
# Execute
result = await service.get_enabled_webhooks()
# Verify
assert result == []
async def test_get_enabled_webhooks_filters_enabled(self):
"""Returns only enabled webhooks."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
# All returned webhooks should be enabled (SQL filter)
webhook1 = _create_mock_webhook(webhook_id=1, name='Enabled 1', enabled=True)
webhook2 = _create_mock_webhook(webhook_id=2, name='Enabled 2', enabled=True)
mock_result = _create_mock_result([webhook1, webhook2])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda model_cls, entity: {
'id': entity.id,
'name': entity.name,
'enabled': entity.enabled,
}
)
service = WebhookService(ap)
# Execute
result = await service.get_enabled_webhooks()
# Verify
assert len(result) == 2
assert all(w['enabled'] for w in result)
async def test_get_enabled_webhooks_filters_disabled(self):
"""Does not return disabled webhooks."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
# Empty result because query filters on enabled=True
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(return_value={})
service = WebhookService(ap)
# Execute
result = await service.get_enabled_webhooks()
# Verify - should be empty (SQL would filter disabled)
assert result == []
@@ -0,0 +1,40 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.api.http.service.apikey import ApiKeyService
@pytest.mark.asyncio
@pytest.mark.parametrize('api_key', [None, 123, b'lbk_bytes', '', 'plain_key', ' LBK_bad', 'sk-lbk_fake'])
async def test_verify_api_key_rejects_non_lbk_keys_without_db_query(api_key):
persistence_mgr = SimpleNamespace(execute_async=AsyncMock())
service = ApiKeyService(SimpleNamespace(persistence_mgr=persistence_mgr))
result = await service.verify_api_key(api_key)
assert result is False
persistence_mgr.execute_async.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize(
('db_row', 'expected'),
[
(object(), True),
(None, False),
],
)
async def test_verify_api_key_keeps_db_validation_for_lbk_keys(db_row, expected):
query_result = Mock()
query_result.first.return_value = db_row
persistence_mgr = SimpleNamespace(execute_async=AsyncMock(return_value=query_result))
service = ApiKeyService(SimpleNamespace(persistence_mgr=persistence_mgr))
result = await service.verify_api_key('lbk_valid_format')
assert result is expected
persistence_mgr.execute_async.assert_awaited_once()
+1
View File
@@ -0,0 +1 @@
# Unit tests for command module
+532
View File
@@ -0,0 +1,532 @@
"""
Unit tests for cmdmgr module - REAL imports.
Tests CommandManager initialization, execute, and privilege handling.
"""
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock
from langbot.pkg.command import operator
from langbot.pkg.command.cmdmgr import CommandManager
from tests.factories import FakeApp, command_query
import langbot_plugin.api.entities.builtin.provider.session as provider_session
class TestCommandManagerInit:
"""Tests for CommandManager initialization."""
def setup_method(self):
"""Save and clear preregistered_operators before each test."""
self._saved_operators = operator.preregistered_operators.copy()
operator.preregistered_operators.clear()
def teardown_method(self):
"""Restore preregistered_operators after each test."""
operator.preregistered_operators.clear()
operator.preregistered_operators.extend(self._saved_operators)
@pytest.mark.asyncio
async def test_init_does_not_set_cmd_list(self):
"""CommandManager.__init__ does not set cmd_list (set in initialize())."""
fake_app = FakeApp()
mgr = CommandManager(fake_app)
assert mgr.ap is fake_app
assert not hasattr(mgr, 'cmd_list') # Not set until initialize()
@pytest.mark.asyncio
async def test_initialize_sets_path_for_top_level_commands(self):
"""initialize() sets path for top-level commands."""
@operator.operator_class(name='help')
class HelpOperator(operator.CommandOperator):
async def execute(self, context):
yield None
@operator.operator_class(name='status')
class StatusOperator(operator.CommandOperator):
async def execute(self, context):
yield None
fake_app = FakeApp()
mgr = CommandManager(fake_app)
await mgr.initialize()
# Check paths are set
help_op = next(op for op in mgr.cmd_list if op.name == 'help')
status_op = next(op for op in mgr.cmd_list if op.name == 'status')
assert help_op.path == 'help'
assert status_op.path == 'status'
@pytest.mark.asyncio
async def test_initialize_sets_path_for_nested_commands(self):
"""initialize() sets path for nested commands."""
@operator.operator_class(name='plugin')
class PluginOperator(operator.CommandOperator):
async def execute(self, context):
yield None
@operator.operator_class(name='list', parent_class=PluginOperator)
class PluginListOperator(operator.CommandOperator):
async def execute(self, context):
yield None
@operator.operator_class(name='install', parent_class=PluginOperator)
class PluginInstallOperator(operator.CommandOperator):
async def execute(self, context):
yield None
fake_app = FakeApp()
mgr = CommandManager(fake_app)
await mgr.initialize()
plugin_op = next(op for op in mgr.cmd_list if op.name == 'plugin')
list_op = next(op for op in mgr.cmd_list if op.name == 'list')
install_op = next(op for op in mgr.cmd_list if op.name == 'install')
assert plugin_op.path == 'plugin'
assert list_op.path == 'plugin.list'
assert install_op.path == 'plugin.install'
@pytest.mark.asyncio
async def test_initialize_sets_children_for_parent_commands(self):
"""initialize() sets children list for parent commands."""
@operator.operator_class(name='parent')
class ParentOperator(operator.CommandOperator):
async def execute(self, context):
yield None
@operator.operator_class(name='child1', parent_class=ParentOperator)
class Child1Operator(operator.CommandOperator):
async def execute(self, context):
yield None
@operator.operator_class(name='child2', parent_class=ParentOperator)
class Child2Operator(operator.CommandOperator):
async def execute(self, context):
yield None
fake_app = FakeApp()
mgr = CommandManager(fake_app)
await mgr.initialize()
parent_op = next(op for op in mgr.cmd_list if op.name == 'parent')
child_names = [child.name for child in parent_op.children]
assert len(parent_op.children) == 2
assert 'child1' in child_names
assert 'child2' in child_names
@pytest.mark.asyncio
async def test_initialize_instantiates_all_operators(self):
"""initialize() instantiates all preregistered operators."""
@operator.operator_class(name='help')
class HelpOperator(operator.CommandOperator):
async def execute(self, context):
yield None
@operator.operator_class(name='status')
class StatusOperator(operator.CommandOperator):
async def execute(self, context):
yield None
fake_app = FakeApp()
mgr = CommandManager(fake_app)
await mgr.initialize()
assert len(mgr.cmd_list) == 2
assert all(isinstance(op, operator.CommandOperator) for op in mgr.cmd_list)
@pytest.mark.asyncio
async def test_initialize_calls_operator_initialize(self):
"""initialize() calls initialize() on each operator."""
init_called = []
@operator.operator_class(name='test')
class TestOperator(operator.CommandOperator):
async def initialize(self):
init_called.append(self.name)
async def execute(self, context):
yield None
fake_app = FakeApp()
mgr = CommandManager(fake_app)
await mgr.initialize()
assert 'test' in init_called
@pytest.mark.asyncio
async def test_initialize_with_no_operators(self):
"""initialize() handles empty preregistered_operators."""
fake_app = FakeApp()
mgr = CommandManager(fake_app)
await mgr.initialize()
assert mgr.cmd_list == []
class TestCommandManagerExecute:
"""Tests for CommandManager execute method."""
def setup_method(self):
"""Save and clear preregistered_operators before each test."""
self._saved_operators = operator.preregistered_operators.copy()
operator.preregistered_operators.clear()
def teardown_method(self):
"""Restore preregistered_operators after each test."""
operator.preregistered_operators.clear()
operator.preregistered_operators.extend(self._saved_operators)
def _create_session(self, launcher_type=provider_session.LauncherTypes.PERSON, launcher_id=12345):
"""Helper to create a session."""
return provider_session.Session(
launcher_type=launcher_type,
launcher_id=launcher_id,
sender_id=launcher_id,
use_prompt_name='default',
using_conversation=None,
conversations=[],
)
@pytest.mark.asyncio
async def test_execute_returns_generator(self):
"""execute() returns an async generator."""
fake_app = FakeApp()
mgr = CommandManager(fake_app)
# Mock plugin_connector.list_commands to return empty list
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[])
query = command_query('help')
session = self._create_session()
result = mgr.execute('help', '/help', query, session)
assert hasattr(result, '__aiter__')
@pytest.mark.asyncio
async def test_execute_sets_privilege_for_admin(self):
"""execute() sets privilege=2 for admin users."""
fake_app = FakeApp(admins=['person_12345'])
mgr = CommandManager(fake_app)
mgr.cmd_list = []
# Mock plugin_connector
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[])
query = command_query('status')
query.launcher_type = provider_session.LauncherTypes.PERSON
query.launcher_id = 12345
session = self._create_session()
results = []
async for ret in mgr.execute('status', '/status', query, session):
results.append(ret)
# Verify admin config was checked
assert 'person_12345' in fake_app.instance_config.data['admins']
@pytest.mark.asyncio
async def test_execute_sets_privilege_for_non_admin(self):
"""execute() sets privilege=1 for non-admin users."""
fake_app = FakeApp(admins=['person_12345'])
mgr = CommandManager(fake_app)
mgr.cmd_list = []
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[])
query = command_query('status')
query.launcher_type = provider_session.LauncherTypes.PERSON
query.launcher_id = 67890 # Not in admins list
session = self._create_session(launcher_id=67890)
results = []
async for ret in mgr.execute('status', '/status', query, session):
results.append(ret)
@pytest.mark.asyncio
async def test_execute_parses_command_text(self):
"""execute() splits command_text into params."""
fake_app = FakeApp()
mgr = CommandManager(fake_app)
mgr.cmd_list = []
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[])
query = command_query('help arg1 arg2')
session = self._create_session()
results = []
async for ret in mgr.execute('help arg1 arg2', '/help arg1 arg2', query, session):
results.append(ret)
# Command text parsing happens inside execute()
# We verify it doesn't crash
@pytest.mark.asyncio
async def test_execute_passes_bound_plugins(self):
"""execute() passes bound_plugins from query variables."""
fake_app = FakeApp()
mgr = CommandManager(fake_app)
mgr.cmd_list = []
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[])
query = command_query('help')
query.variables = {'_pipeline_bound_plugins': ['plugin1', 'plugin2']}
session = self._create_session()
results = []
async for ret in mgr.execute('help', '/help', query, session):
results.append(ret)
# Bound plugins are extracted from query.variables
assert query.variables.get('_pipeline_bound_plugins') == ['plugin1', 'plugin2']
class TestCommandManagerInternalExecute:
"""Tests for CommandManager._execute method."""
def setup_method(self):
"""Save and clear preregistered_operators before each test."""
self._saved_operators = operator.preregistered_operators.copy()
operator.preregistered_operators.clear()
def teardown_method(self):
"""Restore preregistered_operators after each test."""
operator.preregistered_operators.clear()
operator.preregistered_operators.extend(self._saved_operators)
def _create_context(self, command='help', privilege=1):
"""Helper to create ExecuteContext."""
from langbot_plugin.api.entities.builtin.command import context as cmd_context
session = provider_session.Session(
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
use_prompt_name='default',
using_conversation=None,
conversations=[],
)
return cmd_context.ExecuteContext(
query_id=1,
session=session,
command_text='help',
full_command_text='/help',
command=command,
crt_command=command,
params=['help'],
crt_params=['help'],
privilege=privilege,
)
@pytest.mark.asyncio
async def test_execute_yields_command_not_found_error(self):
"""_execute yields CommandNotFoundError for unknown commands."""
fake_app = FakeApp()
mgr = CommandManager(fake_app)
mgr.cmd_list = []
# Mock plugin_connector.list_commands to return empty list
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[])
ctx = self._create_context(command='unknown_cmd')
results = []
async for ret in mgr._execute(ctx, mgr.cmd_list):
results.append(ret)
assert len(results) == 1
assert results[0].error is not None
assert '未知命令' in str(results[0].error)
@pytest.mark.asyncio
async def test_execute_calls_plugin_command(self):
"""_execute calls plugin connector for plugin commands."""
from langbot_plugin.api.entities.builtin.command import context as cmd_context
fake_app = FakeApp()
mgr = CommandManager(fake_app)
mgr.cmd_list = []
# Mock plugin command
mock_command = Mock()
mock_command.metadata.name = 'plugin_cmd'
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[mock_command])
async def mock_plugin_execute(ctx, bound_plugins):
yield cmd_context.CommandReturn(text='plugin response')
fake_app.plugin_connector.execute_command = mock_plugin_execute
ctx = self._create_context(command='plugin_cmd')
results = []
async for ret in mgr._execute(ctx, mgr.cmd_list):
results.append(ret)
assert len(results) == 1
assert results[0].text == 'plugin response'
@pytest.mark.asyncio
async def test_execute_with_bound_plugins(self):
"""_execute passes bound_plugins to plugin connector."""
fake_app = FakeApp()
mgr = CommandManager(fake_app)
mgr.cmd_list = []
# Mock plugin command
mock_command = Mock()
mock_command.metadata.name = 'test_cmd'
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[mock_command])
async def mock_execute_command(ctx, bound_plugins):
yield Mock(text='ok')
fake_app.plugin_connector.execute_command = mock_execute_command
ctx = self._create_context(command='test_cmd')
# Execute with bound_plugins parameter
async for _ in mgr._execute(ctx, mgr.cmd_list, bound_plugins=['test_plugin']):
pass
class TestEmptyAndEdgeInputs:
"""Tests for empty and edge inputs."""
def setup_method(self):
"""Save and clear preregistered_operators before each test."""
self._saved_operators = operator.preregistered_operators.copy()
operator.preregistered_operators.clear()
def teardown_method(self):
"""Restore preregistered_operators after each test."""
operator.preregistered_operators.clear()
operator.preregistered_operators.extend(self._saved_operators)
def _create_session(self):
"""Helper to create a session."""
return provider_session.Session(
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
use_prompt_name='default',
using_conversation=None,
conversations=[],
)
@pytest.mark.asyncio
async def test_execute_with_empty_command_text(self):
"""execute() handles empty command_text."""
fake_app = FakeApp()
mgr = CommandManager(fake_app)
mgr.cmd_list = []
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[])
query = command_query('') # Empty command
session = self._create_session()
results = []
async for ret in mgr.execute('', '/', query, session):
results.append(ret)
# Should yield CommandNotFoundError for empty command
assert len(results) == 1
assert results[0].error is not None
@pytest.mark.asyncio
async def test_execute_with_whitespace_command(self):
"""execute() handles whitespace-only command_text."""
fake_app = FakeApp()
mgr = CommandManager(fake_app)
mgr.cmd_list = []
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[])
query = command_query(' ') # Whitespace command
session = self._create_session()
results = []
async for ret in mgr.execute(' ', '/ ', query, session):
results.append(ret)
# Should yield error
assert len(results) >= 1
@pytest.mark.asyncio
async def test_initialize_with_deep_nesting(self):
"""initialize() handles deeply nested commands."""
@operator.operator_class(name='l1')
class L1Operator(operator.CommandOperator):
async def execute(self, context):
yield None
@operator.operator_class(name='l2', parent_class=L1Operator)
class L2Operator(operator.CommandOperator):
async def execute(self, context):
yield None
@operator.operator_class(name='l3', parent_class=L2Operator)
class L3Operator(operator.CommandOperator):
async def execute(self, context):
yield None
fake_app = FakeApp()
mgr = CommandManager(fake_app)
await mgr.initialize()
l3_op = next(op for op in mgr.cmd_list if op.name == 'l3')
assert l3_op.path == 'l1.l2.l3'
@pytest.mark.asyncio
async def test_execute_with_special_command_name(self):
"""execute() handles special characters in command name."""
fake_app = FakeApp()
mgr = CommandManager(fake_app)
mgr.cmd_list = []
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[])
query = command_query('test-command_123')
session = self._create_session()
results = []
async for ret in mgr.execute('test-command_123', '/test-command_123', query, session):
results.append(ret)
# Should yield CommandNotFoundError (no such command registered)
assert len(results) == 1
assert results[0].error is not None
+302
View File
@@ -0,0 +1,302 @@
"""
Unit tests for operator module - REAL imports.
Tests the operator_class decorator and CommandOperator base class.
"""
from __future__ import annotations
import pytest
from langbot.pkg.command import operator
class TestOperatorClassDecorator:
"""Tests for operator_class decorator."""
def setup_method(self):
"""Save and clear preregistered_operators before each test."""
self._saved_operators = operator.preregistered_operators.copy()
operator.preregistered_operators.clear()
def teardown_method(self):
"""Restore preregistered_operators after each test."""
operator.preregistered_operators.clear()
operator.preregistered_operators.extend(self._saved_operators)
def test_decorator_sets_name(self):
"""Decorator sets command name on class."""
@operator.operator_class(name='test_cmd')
class TestOperator(operator.CommandOperator):
async def execute(self, context):
yield None
assert TestOperator.name == 'test_cmd'
def test_decorator_sets_help(self):
"""Decorator sets help text on class."""
@operator.operator_class(name='test', help='Test help message')
class TestOperator(operator.CommandOperator):
async def execute(self, context):
yield None
assert TestOperator.help == 'Test help message'
def test_decorator_sets_usage(self):
"""Decorator sets usage text on class."""
@operator.operator_class(name='test', usage='!test <arg>')
class TestOperator(operator.CommandOperator):
async def execute(self, context):
yield None
assert TestOperator.usage == '!test <arg>'
def test_decorator_sets_alias(self):
"""Decorator sets alias list on class."""
@operator.operator_class(name='test', alias=['t', 'tst'])
class TestOperator(operator.CommandOperator):
async def execute(self, context):
yield None
assert TestOperator.alias == ['t', 'tst']
def test_decorator_sets_privilege_default(self):
"""Decorator sets default privilege to 1 (normal user)."""
@operator.operator_class(name='test')
class TestOperator(operator.CommandOperator):
async def execute(self, context):
yield None
assert TestOperator.lowest_privilege == 1
def test_decorator_sets_privilege_admin(self):
"""Decorator sets privilege to 2 for admin commands."""
@operator.operator_class(name='admin_cmd', privilege=2)
class TestOperator(operator.CommandOperator):
async def execute(self, context):
yield None
assert TestOperator.lowest_privilege == 2
def test_decorator_sets_parent_class_none(self):
"""Decorator sets parent_class to None for top-level commands."""
@operator.operator_class(name='test')
class TestOperator(operator.CommandOperator):
async def execute(self, context):
yield None
assert TestOperator.parent_class is None
def test_decorator_sets_parent_class(self):
"""Decorator sets parent_class for sub-commands."""
@operator.operator_class(name='parent')
class ParentOperator(operator.CommandOperator):
async def execute(self, context):
yield None
@operator.operator_class(name='child', parent_class=ParentOperator)
class ChildOperator(operator.CommandOperator):
async def execute(self, context):
yield None
assert ChildOperator.parent_class is ParentOperator
def test_decorator_registers_to_preregistered_list(self):
"""Decorator appends class to preregistered_operators."""
@operator.operator_class(name='test1')
class TestOperator1(operator.CommandOperator):
async def execute(self, context):
yield None
@operator.operator_class(name='test2')
class TestOperator2(operator.CommandOperator):
async def execute(self, context):
yield None
assert TestOperator1 in operator.preregistered_operators
assert TestOperator2 in operator.preregistered_operators
def test_decorator_requires_command_operator_subclass(self):
"""Decorator asserts class is subclass of CommandOperator."""
with pytest.raises(AssertionError):
operator.operator_class(name='invalid')(object)
class TestCommandOperatorBase:
"""Tests for CommandOperator base class."""
def setup_method(self):
"""Save and clear preregistered_operators before each test."""
self._saved_operators = operator.preregistered_operators.copy()
operator.preregistered_operators.clear()
def teardown_method(self):
"""Restore preregistered_operators after each test."""
operator.preregistered_operators.clear()
operator.preregistered_operators.extend(self._saved_operators)
def test_init_sets_app(self):
"""__init__ stores application reference."""
class MockApp:
pass
@operator.operator_class(name='test')
class TestOperator(operator.CommandOperator):
async def execute(self, context):
yield None
app = MockApp()
op = TestOperator(app)
assert op.ap is app
def test_init_sets_empty_children(self):
"""__init__ initializes empty children list."""
@operator.operator_class(name='test')
class TestOperator(operator.CommandOperator):
async def execute(self, context):
yield None
op = TestOperator(None)
assert op.children == []
def test_class_has_required_attributes(self):
"""CommandOperator has required class attributes."""
@operator.operator_class(name='test')
class TestOperator(operator.CommandOperator):
async def execute(self, context):
yield None
assert hasattr(TestOperator, 'name')
assert hasattr(TestOperator, 'alias')
assert hasattr(TestOperator, 'help')
assert hasattr(TestOperator, 'usage')
assert hasattr(TestOperator, 'parent_class')
assert hasattr(TestOperator, 'lowest_privilege')
def test_initialize_is_async_noop(self):
"""Default initialize() is async no-op."""
@operator.operator_class(name='test')
class TestOperator(operator.CommandOperator):
async def execute(self, context):
yield None
op = TestOperator(None)
# Should not raise
import asyncio
asyncio.get_event_loop().run_until_complete(op.initialize())
def test_execute_is_abstract(self):
"""execute() must be implemented by subclass."""
# Cannot instantiate abstract class
with pytest.raises(TypeError):
operator.CommandOperator(None)
def test_path_not_set_by_decorator(self):
"""path is not set by decorator, set by CommandManager."""
@operator.operator_class(name='test')
class TestOperator(operator.CommandOperator):
async def execute(self, context):
yield None
# path should not exist initially
assert not hasattr(TestOperator, 'path') or TestOperator.path is None
class TestMultipleOperators:
"""Tests for multiple operator registration and hierarchy."""
def setup_method(self):
"""Save and clear preregistered_operators before each test."""
self._saved_operators = operator.preregistered_operators.copy()
operator.preregistered_operators.clear()
def teardown_method(self):
"""Restore preregistered_operators after each test."""
operator.preregistered_operators.clear()
operator.preregistered_operators.extend(self._saved_operators)
def test_multiple_independent_operators(self):
"""Multiple independent operators can be registered."""
@operator.operator_class(name='help')
class HelpOperator(operator.CommandOperator):
async def execute(self, context):
yield None
@operator.operator_class(name='status')
class StatusOperator(operator.CommandOperator):
async def execute(self, context):
yield None
@operator.operator_class(name='version')
class VersionOperator(operator.CommandOperator):
async def execute(self, context):
yield None
assert len(operator.preregistered_operators) == 3
names = [op.name for op in operator.preregistered_operators]
assert 'help' in names
assert 'status' in names
assert 'version' in names
def test_parent_child_hierarchy(self):
"""Parent-child hierarchy can be established."""
@operator.operator_class(name='plugin')
class PluginOperator(operator.CommandOperator):
async def execute(self, context):
yield None
@operator.operator_class(name='list', parent_class=PluginOperator)
class PluginListOperator(operator.CommandOperator):
async def execute(self, context):
yield None
@operator.operator_class(name='install', parent_class=PluginOperator)
class PluginInstallOperator(operator.CommandOperator):
async def execute(self, context):
yield None
# Both parent and children are in preregistered list
assert len(operator.preregistered_operators) == 3
# Parent-child relationships are established via parent_class
plugin_op = next(op for op in operator.preregistered_operators if op.name == 'plugin')
list_op = next(op for op in operator.preregistered_operators if op.name == 'list')
install_op = next(op for op in operator.preregistered_operators if op.name == 'install')
assert plugin_op.parent_class is None
assert list_op.parent_class is PluginOperator
assert install_op.parent_class is PluginOperator
def test_privilege_inheritance_not_automatic(self):
"""Child operators do not automatically inherit parent privilege."""
@operator.operator_class(name='admin', privilege=2)
class AdminOperator(operator.CommandOperator):
async def execute(self, context):
yield None
@operator.operator_class(name='sub', parent_class=AdminOperator, privilege=1)
class SubOperator(operator.CommandOperator):
async def execute(self, context):
yield None
assert AdminOperator.lowest_privilege == 2
assert SubOperator.lowest_privilege == 1
@@ -0,0 +1,309 @@
"""
Unit tests for configuration loading and overrides.
Tests cover:
- Valid YAML config loading
- Valid JSON config loading
- Invalid YAML/JSON error behavior
- Missing config file behavior
- Template completion
"""
from __future__ import annotations
import pytest
import json
from langbot.pkg.config.impls.yaml import YAMLConfigFile
from langbot.pkg.config.impls.json import JSONConfigFile
from langbot.pkg.config.manager import ConfigManager
class TestYAMLConfigFile:
"""Tests for YAML config file handling."""
@pytest.mark.asyncio
async def test_valid_yaml_loads(self, tmp_path):
"""Valid YAML config should load correctly."""
config_file = tmp_path / "test_config.yaml"
# Write valid YAML
config_file.write_text("""
name: test_app
version: 1.0
settings:
debug: true
port: 8080
""")
yaml_file = YAMLConfigFile(
str(config_file),
template_data={'name': 'default', 'version': '0.1'},
)
result = await yaml_file.load(completion=False)
assert result['name'] == 'test_app'
assert result['version'] == 1.0
assert result['settings']['debug'] is True
assert result['settings']['port'] == 8080
@pytest.mark.asyncio
async def test_invalid_yaml_raises_error(self, tmp_path):
"""Invalid YAML should raise clear error."""
config_file = tmp_path / "invalid.yaml"
# Write invalid YAML (unclosed bracket)
config_file.write_text("""
name: test
settings:
- item1
- item2
- [unclosed
""")
yaml_file = YAMLConfigFile(
str(config_file),
template_data={'name': 'default'},
)
with pytest.raises(Exception, match="Syntax error"):
await yaml_file.load(completion=False)
@pytest.mark.asyncio
async def test_missing_config_creates_from_template(self, tmp_path):
"""Missing config file should be created from template."""
config_file = tmp_path / "new_config.yaml"
# File doesn't exist yet
assert not config_file.exists()
yaml_file = YAMLConfigFile(
str(config_file),
template_data={'name': 'new_app', 'version': '1.0'},
)
result = await yaml_file.load()
assert config_file.exists()
assert result['name'] == 'new_app'
assert result['version'] == '1.0'
@pytest.mark.asyncio
async def test_template_completion(self, tmp_path):
"""Config should be completed with template defaults."""
config_file = tmp_path / "partial.yaml"
# Write partial config missing some template keys
config_file.write_text("""
name: custom_name
""")
yaml_file = YAMLConfigFile(
str(config_file),
template_data={'name': 'default_name', 'version': '2.0', 'debug': False},
)
result = await yaml_file.load(completion=True)
# Existing key preserved
assert result['name'] == 'custom_name'
# Missing keys filled from template
assert result['version'] == '2.0'
assert result['debug'] is False
@pytest.mark.asyncio
async def test_yaml_save(self, tmp_path):
"""YAML config can be saved."""
config_file = tmp_path / "save_test.yaml"
yaml_file = YAMLConfigFile(
str(config_file),
template_data={'name': 'test'},
)
await yaml_file.save({'name': 'saved_app', 'new_key': 'new_value'})
assert config_file.exists()
content = config_file.read_text()
assert 'saved_app' in content
assert 'new_key' in content
def test_yaml_save_sync(self, tmp_path):
"""YAML config can be saved synchronously."""
config_file = tmp_path / "sync_save.yaml"
yaml_file = YAMLConfigFile(
str(config_file),
template_data={'name': 'test'},
)
yaml_file.save_sync({'name': 'sync_saved'})
assert config_file.exists()
content = config_file.read_text()
assert 'sync_saved' in content
class TestJSONConfigFile:
"""Tests for JSON config file handling."""
@pytest.mark.asyncio
async def test_valid_json_loads(self, tmp_path):
"""Valid JSON config should load correctly."""
config_file = tmp_path / "test_config.json"
# Write valid JSON
config_file.write_text(json.dumps({
'name': 'json_app',
'version': '1.0',
'settings': {'debug': True, 'port': 8080},
}))
json_file = JSONConfigFile(
str(config_file),
template_data={'name': 'default', 'version': '0.1'},
)
result = await json_file.load(completion=False)
assert result['name'] == 'json_app'
assert result['version'] == '1.0'
assert result['settings']['debug'] is True
@pytest.mark.asyncio
async def test_invalid_json_raises_error(self, tmp_path):
"""Invalid JSON should raise clear error."""
config_file = tmp_path / "invalid.json"
# Write invalid JSON (missing closing brace)
config_file.write_text('{"name": "test", "unclosed": ')
json_file = JSONConfigFile(
str(config_file),
template_data={'name': 'default'},
)
with pytest.raises(Exception, match="Syntax error"):
await json_file.load(completion=False)
@pytest.mark.asyncio
async def test_missing_json_creates_from_template(self, tmp_path):
"""Missing JSON file should be created from template."""
config_file = tmp_path / "new_config.json"
json_file = JSONConfigFile(
str(config_file),
template_data={'name': 'new_json_app', 'version': '1.0'},
)
result = await json_file.load()
assert config_file.exists()
assert result['name'] == 'new_json_app'
@pytest.mark.asyncio
async def test_json_save(self, tmp_path):
"""JSON config can be saved."""
config_file = tmp_path / "save_test.json"
json_file = JSONConfigFile(
str(config_file),
template_data={'name': 'test'},
)
await json_file.save({'name': 'saved_json', 'new_key': 'value'})
assert config_file.exists()
content = config_file.read_text()
data = json.loads(content)
assert data['name'] == 'saved_json'
class TestConfigManager:
"""Tests for ConfigManager."""
@pytest.mark.asyncio
async def test_config_manager_load(self, tmp_path):
"""ConfigManager loads config correctly."""
config_file = tmp_path / "manager_test.yaml"
config_file.write_text('name: managed_app\nversion: "1.0"\n')
yaml_file = YAMLConfigFile(
str(config_file),
template_data={'name': 'default', 'version': '0.1'},
)
manager = ConfigManager(yaml_file)
await manager.load_config()
assert manager.data['name'] == 'managed_app'
assert manager.data['version'] == '1.0'
@pytest.mark.asyncio
async def test_config_manager_dump(self, tmp_path):
"""ConfigManager can dump config."""
config_file = tmp_path / "dump_test.yaml"
yaml_file = YAMLConfigFile(
str(config_file),
template_data={'name': 'default'},
)
manager = ConfigManager(yaml_file)
manager.data = {'name': 'dumped', 'new_field': 'value'}
await manager.dump_config()
content = config_file.read_text()
assert 'dumped' in content
def test_config_manager_dump_sync(self, tmp_path):
"""ConfigManager can dump config synchronously."""
config_file = tmp_path / "sync_dump.yaml"
yaml_file = YAMLConfigFile(
str(config_file),
template_data={'name': 'default'},
)
manager = ConfigManager(yaml_file)
manager.data = {'name': 'sync_dumped'}
manager.dump_config_sync()
assert config_file.exists()
class TestConfigExists:
"""Tests for config file existence check."""
def test_yaml_exists_true(self, tmp_path):
"""exists() returns True for existing file."""
config_file = tmp_path / "exists.yaml"
config_file.write_text('name: test')
yaml_file = YAMLConfigFile(str(config_file), template_data={})
assert yaml_file.exists() is True
def test_yaml_exists_false(self, tmp_path):
"""exists() returns False for missing file."""
config_file = tmp_path / "missing.yaml"
yaml_file = YAMLConfigFile(str(config_file), template_data={})
assert yaml_file.exists() is False
def test_json_exists_true(self, tmp_path):
"""exists() returns True for existing JSON file."""
config_file = tmp_path / "exists.json"
config_file.write_text('{}')
json_file = JSONConfigFile(str(config_file), template_data={})
assert json_file.exists() is True
def test_json_exists_false(self, tmp_path):
"""exists() returns False for missing JSON file."""
config_file = tmp_path / "missing.json"
json_file = JSONConfigFile(str(config_file), template_data={})
assert json_file.exists() is False
@@ -1,267 +0,0 @@
"""
Tests for environment variable override functionality in YAML config
"""
import os
import pytest
from typing import Any
def _apply_env_overrides_to_config(cfg: dict) -> dict:
"""Apply environment variable overrides to data/config.yaml
Environment variables should be uppercase and use __ (double underscore)
to represent nested keys. For example:
- CONCURRENCY__PIPELINE overrides concurrency.pipeline
- PLUGIN__RUNTIME_WS_URL overrides plugin.runtime_ws_url
Arrays and dict types are ignored.
Args:
cfg: Configuration dictionary
Returns:
Updated configuration dictionary
"""
def convert_value(value: str, original_value: Any) -> Any:
"""Convert string value to appropriate type based on original value
Args:
value: String value from environment variable
original_value: Original value to infer type from
Returns:
Converted value (falls back to string if conversion fails)
"""
if isinstance(original_value, bool):
return value.lower() in ('true', '1', 'yes', 'on')
elif isinstance(original_value, int):
try:
return int(value)
except ValueError:
# If conversion fails, keep as string (user error, but non-breaking)
return value
elif isinstance(original_value, float):
try:
return float(value)
except ValueError:
# If conversion fails, keep as string (user error, but non-breaking)
return value
else:
return value
# Process environment variables
for env_key, env_value in os.environ.items():
# Check if the environment variable is uppercase and contains __
if not env_key.isupper():
continue
if '__' not in env_key:
continue
# Convert environment variable name to config path
# e.g., CONCURRENCY__PIPELINE -> ['concurrency', 'pipeline']
keys = [key.lower() for key in env_key.split('__')]
# Navigate to the target value and validate the path
current = cfg
for i, key in enumerate(keys):
if not isinstance(current, dict) or key not in current:
break
if i == len(keys) - 1:
# At the final key - check if it's a scalar value
if isinstance(current[key], (dict, list)):
# Skip dict and list types
pass
else:
# Valid scalar value - convert and set it
converted_value = convert_value(env_value, current[key])
current[key] = converted_value
else:
# Navigate deeper
current = current[key]
return cfg
class TestEnvOverrides:
"""Test environment variable override functionality"""
def test_simple_string_override(self):
"""Test overriding a simple string value"""
cfg = {'api': {'port': 5300}}
# Set environment variable
os.environ['API__PORT'] = '8080'
result = _apply_env_overrides_to_config(cfg)
assert result['api']['port'] == 8080
# Cleanup
del os.environ['API__PORT']
def test_nested_key_override(self):
"""Test overriding nested keys with __ delimiter"""
cfg = {'concurrency': {'pipeline': 20, 'session': 1}}
os.environ['CONCURRENCY__PIPELINE'] = '50'
result = _apply_env_overrides_to_config(cfg)
assert result['concurrency']['pipeline'] == 50
assert result['concurrency']['session'] == 1 # Unchanged
del os.environ['CONCURRENCY__PIPELINE']
def test_deep_nested_override(self):
"""Test overriding deeply nested keys"""
cfg = {'system': {'jwt': {'expire': 604800, 'secret': ''}}}
os.environ['SYSTEM__JWT__EXPIRE'] = '86400'
os.environ['SYSTEM__JWT__SECRET'] = 'my_secret_key'
result = _apply_env_overrides_to_config(cfg)
assert result['system']['jwt']['expire'] == 86400
assert result['system']['jwt']['secret'] == 'my_secret_key'
del os.environ['SYSTEM__JWT__EXPIRE']
del os.environ['SYSTEM__JWT__SECRET']
def test_underscore_in_key(self):
"""Test keys with underscores like runtime_ws_url"""
cfg = {'plugin': {'enable': True, 'runtime_ws_url': 'ws://localhost:5400/control/ws'}}
os.environ['PLUGIN__RUNTIME_WS_URL'] = 'ws://newhost:6000/ws'
result = _apply_env_overrides_to_config(cfg)
assert result['plugin']['runtime_ws_url'] == 'ws://newhost:6000/ws'
del os.environ['PLUGIN__RUNTIME_WS_URL']
def test_boolean_conversion(self):
"""Test boolean value conversion"""
cfg = {'plugin': {'enable': True, 'enable_marketplace': False}}
os.environ['PLUGIN__ENABLE'] = 'false'
os.environ['PLUGIN__ENABLE_MARKETPLACE'] = 'true'
result = _apply_env_overrides_to_config(cfg)
assert result['plugin']['enable'] is False
assert result['plugin']['enable_marketplace'] is True
del os.environ['PLUGIN__ENABLE']
del os.environ['PLUGIN__ENABLE_MARKETPLACE']
def test_ignore_dict_type(self):
"""Test that dict types are ignored"""
cfg = {'database': {'use': 'sqlite', 'sqlite': {'path': 'data/langbot.db'}}}
# Try to override a dict value - should be ignored
os.environ['DATABASE__SQLITE'] = 'new_value'
result = _apply_env_overrides_to_config(cfg)
# Should remain a dict, not overridden
assert isinstance(result['database']['sqlite'], dict)
assert result['database']['sqlite']['path'] == 'data/langbot.db'
del os.environ['DATABASE__SQLITE']
def test_ignore_list_type(self):
"""Test that list/array types are ignored"""
cfg = {'admins': ['admin1', 'admin2'], 'command': {'enable': True, 'prefix': ['!', '']}}
# Try to override list values - should be ignored
os.environ['ADMINS'] = 'admin3'
os.environ['COMMAND__PREFIX'] = '?'
result = _apply_env_overrides_to_config(cfg)
# Should remain lists, not overridden
assert isinstance(result['admins'], list)
assert result['admins'] == ['admin1', 'admin2']
assert isinstance(result['command']['prefix'], list)
assert result['command']['prefix'] == ['!', '']
del os.environ['ADMINS']
del os.environ['COMMAND__PREFIX']
def test_lowercase_env_var_ignored(self):
"""Test that lowercase environment variables are ignored"""
cfg = {'api': {'port': 5300}}
os.environ['api__port'] = '8080'
result = _apply_env_overrides_to_config(cfg)
# Should not be overridden
assert result['api']['port'] == 5300
del os.environ['api__port']
def test_no_double_underscore_ignored(self):
"""Test that env vars without __ are ignored"""
cfg = {'api': {'port': 5300}}
os.environ['APIPORT'] = '8080'
result = _apply_env_overrides_to_config(cfg)
# Should not be overridden
assert result['api']['port'] == 5300
del os.environ['APIPORT']
def test_nonexistent_key_ignored(self):
"""Test that env vars for non-existent keys are ignored"""
cfg = {'api': {'port': 5300}}
os.environ['API__NONEXISTENT'] = 'value'
result = _apply_env_overrides_to_config(cfg)
# Should not create new key
assert 'nonexistent' not in result['api']
del os.environ['API__NONEXISTENT']
def test_integer_conversion(self):
"""Test integer value conversion"""
cfg = {'concurrency': {'pipeline': 20}}
os.environ['CONCURRENCY__PIPELINE'] = '100'
result = _apply_env_overrides_to_config(cfg)
assert result['concurrency']['pipeline'] == 100
assert isinstance(result['concurrency']['pipeline'], int)
del os.environ['CONCURRENCY__PIPELINE']
def test_multiple_overrides(self):
"""Test multiple environment variable overrides at once"""
cfg = {'api': {'port': 5300}, 'concurrency': {'pipeline': 20, 'session': 1}, 'plugin': {'enable': False}}
os.environ['API__PORT'] = '8080'
os.environ['CONCURRENCY__PIPELINE'] = '50'
os.environ['PLUGIN__ENABLE'] = 'true'
result = _apply_env_overrides_to_config(cfg)
assert result['api']['port'] == 8080
assert result['concurrency']['pipeline'] == 50
assert result['plugin']['enable'] is True
del os.environ['API__PORT']
del os.environ['CONCURRENCY__PIPELINE']
del os.environ['PLUGIN__ENABLE']
if __name__ == '__main__':
pytest.main([__file__, '-v'])
@@ -1,175 +0,0 @@
"""
Tests for webhook_prefix configuration
"""
import os
import pytest
from typing import Any
def _apply_env_overrides_to_config(cfg: dict) -> dict:
"""Apply environment variable overrides to data/config.yaml
Environment variables should be uppercase and use __ (double underscore)
to represent nested keys. For example:
- CONCURRENCY__PIPELINE overrides concurrency.pipeline
- PLUGIN__RUNTIME_WS_URL overrides plugin.runtime_ws_url
Arrays and dict types are ignored.
Args:
cfg: Configuration dictionary
Returns:
Updated configuration dictionary
"""
def convert_value(value: str, original_value: Any) -> Any:
"""Convert string value to appropriate type based on original value
Args:
value: String value from environment variable
original_value: Original value to infer type from
Returns:
Converted value (falls back to string if conversion fails)
"""
if isinstance(original_value, bool):
return value.lower() in ('true', '1', 'yes', 'on')
elif isinstance(original_value, int):
try:
return int(value)
except ValueError:
# If conversion fails, keep as string (user error, but non-breaking)
return value
elif isinstance(original_value, float):
try:
return float(value)
except ValueError:
# If conversion fails, keep as string (user error, but non-breaking)
return value
else:
return value
# Process environment variables
for env_key, env_value in os.environ.items():
# Check if the environment variable is uppercase and contains __
if not env_key.isupper():
continue
if '__' not in env_key:
continue
# Convert environment variable name to config path
# e.g., CONCURRENCY__PIPELINE -> ['concurrency', 'pipeline']
keys = [key.lower() for key in env_key.split('__')]
# Navigate to the target value and validate the path
current = cfg
for i, key in enumerate(keys):
if not isinstance(current, dict) or key not in current:
break
if i == len(keys) - 1:
# At the final key - check if it's a scalar value
if isinstance(current[key], (dict, list)):
# Skip dict and list types
pass
else:
# Valid scalar value - convert and set it
converted_value = convert_value(env_value, current[key])
current[key] = converted_value
else:
# Navigate deeper
current = current[key]
return cfg
class TestWebhookDisplayPrefix:
"""Test webhook_prefix configuration functionality"""
def test_default_webhook_prefix(self):
"""Test that the default webhook display prefix is correctly set"""
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
# Should have the default value
assert cfg['api']['webhook_prefix'] == 'http://127.0.0.1:5300'
assert cfg['api']['extra_webhook_prefix'] == ''
def test_webhook_prefix_env_override(self):
"""Test overriding webhook_prefix via environment variable"""
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
# Set environment variable
os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com:8080'
result = _apply_env_overrides_to_config(cfg)
assert result['api']['webhook_prefix'] == 'https://example.com:8080'
# Cleanup
del os.environ['API__WEBHOOK_PREFIX']
def test_webhook_prefix_with_custom_domain(self):
"""Test webhook_prefix with custom domain"""
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
# Set to a custom domain
os.environ['API__WEBHOOK_PREFIX'] = 'https://bot.mycompany.com'
result = _apply_env_overrides_to_config(cfg)
assert result['api']['webhook_prefix'] == 'https://bot.mycompany.com'
# Cleanup
del os.environ['API__WEBHOOK_PREFIX']
def test_webhook_prefix_with_subdirectory(self):
"""Test webhook_prefix with subdirectory path"""
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
# Set to a URL with subdirectory
os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com/langbot'
result = _apply_env_overrides_to_config(cfg)
assert result['api']['webhook_prefix'] == 'https://example.com/langbot'
# Cleanup
del os.environ['API__WEBHOOK_PREFIX']
def test_extra_webhook_prefix_default_empty(self):
"""Test that extra_webhook_prefix defaults to empty string"""
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
bot_uuid = 'test-bot-uuid'
webhook_prefix = cfg['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
extra_webhook_prefix = cfg['api'].get('extra_webhook_prefix', '')
webhook_url = f'/bots/{bot_uuid}'
assert f'{webhook_prefix}{webhook_url}' == 'http://127.0.0.1:5300/bots/test-bot-uuid'
# extra should be empty when not configured
assert extra_webhook_prefix == ''
def test_extra_webhook_prefix_env_override(self):
"""Test overriding extra_webhook_prefix via environment variable"""
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
os.environ['API__EXTRA_WEBHOOK_PREFIX'] = 'https://extra.example.com'
result = _apply_env_overrides_to_config(cfg)
assert result['api']['extra_webhook_prefix'] == 'https://extra.example.com'
bot_uuid = 'test-bot-uuid'
extra_prefix = result['api']['extra_webhook_prefix']
webhook_url = f'/bots/{bot_uuid}'
assert f'{extra_prefix}{webhook_url}' == 'https://extra.example.com/bots/test-bot-uuid'
# Cleanup
del os.environ['API__EXTRA_WEBHOOK_PREFIX']
if __name__ == '__main__':
pytest.main([__file__, '-v'])
+1
View File
@@ -0,0 +1 @@
"""Core module unit tests."""
@@ -0,0 +1,191 @@
"""Unit tests for core app config validation methods.
Tests cover:
- _get_positive_int_config() validation
- _get_positive_float_config() validation
"""
from __future__ import annotations
from unittest.mock import Mock
from importlib import import_module
def get_app_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.core.app')
class TestGetPositiveIntConfig:
"""Tests for _get_positive_int_config method."""
def test_returns_value_when_valid_positive_int(self):
"""Test returns parsed int for valid positive value."""
app_module = get_app_module()
mock_logger = Mock()
app = app_module.Application()
app.logger = mock_logger
result = app._get_positive_int_config(10, default=30, name='test.config')
assert result == 10
mock_logger.warning.assert_not_called()
def test_returns_value_when_valid_string_int(self):
"""Test returns parsed int for string value."""
app_module = get_app_module()
mock_logger = Mock()
app = app_module.Application()
app.logger = mock_logger
result = app._get_positive_int_config('50', default=30, name='test.config')
assert result == 50
mock_logger.warning.assert_not_called()
def test_returns_default_for_zero(self):
"""Test returns default when value is zero."""
app_module = get_app_module()
mock_logger = Mock()
app = app_module.Application()
app.logger = mock_logger
result = app._get_positive_int_config(0, default=30, name='test.config')
assert result == 30
mock_logger.warning.assert_called_once()
def test_returns_default_for_negative(self):
"""Test returns default when value is negative."""
app_module = get_app_module()
mock_logger = Mock()
app = app_module.Application()
app.logger = mock_logger
result = app._get_positive_int_config(-5, default=30, name='test.config')
assert result == 30
mock_logger.warning.assert_called_once()
def test_returns_default_for_invalid_string(self):
"""Test returns default when value is invalid string."""
app_module = get_app_module()
mock_logger = Mock()
app = app_module.Application()
app.logger = mock_logger
result = app._get_positive_int_config('invalid', default=30, name='test.config')
assert result == 30
mock_logger.warning.assert_called_once()
def test_returns_default_for_none(self):
"""Test returns default when value is None."""
app_module = get_app_module()
mock_logger = Mock()
app = app_module.Application()
app.logger = mock_logger
result = app._get_positive_int_config(None, default=30, name='test.config')
assert result == 30
mock_logger.warning.assert_called_once()
class TestGetPositiveFloatConfig:
"""Tests for _get_positive_float_config method."""
def test_returns_value_when_valid_positive_float(self):
"""Test returns parsed float for valid positive value."""
app_module = get_app_module()
mock_logger = Mock()
app = app_module.Application()
app.logger = mock_logger
result = app._get_positive_float_config(1.5, default=2.0, name='test.config')
assert result == 1.5
mock_logger.warning.assert_not_called()
def test_returns_value_when_valid_int(self):
"""Test returns float for valid int value."""
app_module = get_app_module()
mock_logger = Mock()
app = app_module.Application()
app.logger = mock_logger
result = app._get_positive_float_config(2, default=1.0, name='test.config')
assert result == 2.0
mock_logger.warning.assert_not_called()
def test_returns_value_when_valid_string_float(self):
"""Test returns parsed float for string value."""
app_module = get_app_module()
mock_logger = Mock()
app = app_module.Application()
app.logger = mock_logger
result = app._get_positive_float_config('0.5', default=1.0, name='test.config')
assert result == 0.5
mock_logger.warning.assert_not_called()
def test_returns_default_for_zero(self):
"""Test returns default when value is zero."""
app_module = get_app_module()
mock_logger = Mock()
app = app_module.Application()
app.logger = mock_logger
result = app._get_positive_float_config(0.0, default=1.0, name='test.config')
assert result == 1.0
mock_logger.warning.assert_called_once()
def test_returns_default_for_negative(self):
"""Test returns default when value is negative."""
app_module = get_app_module()
mock_logger = Mock()
app = app_module.Application()
app.logger = mock_logger
result = app._get_positive_float_config(-1.0, default=2.0, name='test.config')
assert result == 2.0
mock_logger.warning.assert_called_once()
def test_returns_default_for_invalid_string(self):
"""Test returns default when value is invalid string."""
app_module = get_app_module()
mock_logger = Mock()
app = app_module.Application()
app.logger = mock_logger
result = app._get_positive_float_config('not-a-number', default=1.5, name='test.config')
assert result == 1.5
mock_logger.warning.assert_called_once()
+64
View File
@@ -0,0 +1,64 @@
from __future__ import annotations
import signal
from types import SimpleNamespace
import pytest
from langbot.pkg.core import boot
@pytest.mark.asyncio
async def test_main_signal_handler_handles_sigint_before_app_created(monkeypatch):
captured_handler = {}
def fake_signal(sig, handler):
captured_handler[sig] = handler
async def fake_make_app(loop):
captured_handler[signal.SIGINT](signal.SIGINT, None)
def fake_exit(code):
raise SystemExit(code)
monkeypatch.setattr(signal, 'signal', fake_signal)
monkeypatch.setattr(boot, 'make_app', fake_make_app)
monkeypatch.setattr(boot.os, '_exit', fake_exit)
with pytest.raises(SystemExit) as exc_info:
await boot.main(SimpleNamespace())
assert exc_info.value.code == 0
@pytest.mark.asyncio
async def test_main_signal_handler_disposes_created_app(monkeypatch):
captured_handler = {}
app_inst = SimpleNamespace(disposed=False)
def fake_signal(sig, handler):
captured_handler[sig] = handler
def dispose():
app_inst.disposed = True
async def run():
captured_handler[signal.SIGINT](signal.SIGINT, None)
async def fake_make_app(loop):
app_inst.dispose = dispose
app_inst.run = run
return app_inst
def fake_exit(code):
raise SystemExit(code)
monkeypatch.setattr(signal, 'signal', fake_signal)
monkeypatch.setattr(boot, 'make_app', fake_make_app)
monkeypatch.setattr(boot.os, '_exit', fake_exit)
with pytest.raises(SystemExit) as exc_info:
await boot.main(SimpleNamespace())
assert exc_info.value.code == 0
assert app_inst.disposed is True
@@ -0,0 +1,134 @@
"""Tests for core bootutils dependency checking."""
from __future__ import annotations
import importlib.util
from unittest.mock import MagicMock, patch
from tests.utils.import_isolation import isolated_sys_modules
class TestCheckDeps:
"""Tests for check_deps function."""
def _make_deps_import_mocks(self):
"""Create mocks for deps import."""
return {
'langbot.pkg.utils.pkgmgr': MagicMock(),
}
def test_check_deps_all_present(self):
"""check_deps returns empty list when all deps present."""
mocks = self._make_deps_import_mocks()
with isolated_sys_modules(mocks):
# Mock find_spec to always return a spec (module found)
with patch.object(importlib.util, 'find_spec', return_value=MagicMock()):
from langbot.pkg.core.bootutils.deps import check_deps
import asyncio
result = asyncio.get_event_loop().run_until_complete(check_deps())
assert result == []
def test_check_deps_missing_deps(self):
"""check_deps returns list of missing deps."""
mocks = self._make_deps_import_mocks()
with isolated_sys_modules(mocks):
# Mock find_spec to return None for some deps
def mock_find_spec(name):
if name in ['requests', 'openai']:
return None # Missing
return MagicMock() # Present
with patch.object(importlib.util, 'find_spec', side_effect=mock_find_spec):
from langbot.pkg.core.bootutils.deps import check_deps
import asyncio
result = asyncio.get_event_loop().run_until_complete(check_deps())
assert 'requests' in result
assert 'openai' in result
def test_check_deps_all_missing(self):
"""check_deps returns all deps when none present."""
mocks = self._make_deps_import_mocks()
with isolated_sys_modules(mocks):
# Mock find_spec to always return None
with patch.object(importlib.util, 'find_spec', return_value=None):
from langbot.pkg.core.bootutils.deps import check_deps, required_deps
import asyncio
result = asyncio.get_event_loop().run_until_complete(check_deps())
# Should include all required_deps keys
assert len(result) == len(required_deps)
def test_required_deps_dict_exists(self):
"""required_deps dictionary is defined."""
mocks = self._make_deps_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.bootutils.deps import required_deps
assert isinstance(required_deps, dict)
assert len(required_deps) > 0
# Check some expected deps
assert 'requests' in required_deps
assert 'yaml' in required_deps
def test_required_deps_maps_import_name_to_package_name(self):
"""required_deps maps import name to package name."""
mocks = self._make_deps_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.bootutils.deps import required_deps
# Some import names differ from package names
assert required_deps['PIL'] == 'pillow'
assert required_deps['yaml'] == 'pyyaml'
assert required_deps['jwt'] == 'pyjwt'
class TestPrecheckPluginDeps:
"""Tests for precheck_plugin_deps function."""
def _make_deps_import_mocks(self):
return {
'langbot.pkg.utils.pkgmgr': MagicMock(),
}
def test_precheck_plugin_deps_no_plugins_dir(self):
"""precheck_plugin_deps skips when plugins dir doesn't exist."""
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())
mock_install.assert_not_called()
def test_precheck_plugin_deps_with_plugins_dir(self):
"""precheck_plugin_deps checks plugins subdirectories."""
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 []
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())
mock_install.assert_called_once_with('plugins/plugin1/requirements.txt', extra_params=[])
+290
View File
@@ -0,0 +1,290 @@
"""Unit tests for core stages load_config _apply_env_overrides_to_config.
Tests cover:
- Environment variable parsing and path conversion
- Type conversion (bool, int, float, string)
- List handling (comma-separated)
- Dict type skipping
- Missing key creation
"""
from __future__ import annotations
import os
from unittest.mock import patch
from importlib import import_module
def get_load_config_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.core.stages.load_config')
class TestApplyEnvOverridesToConfig:
"""Tests for _apply_env_overrides_to_config function."""
def test_override_string_value(self):
"""Test overriding an existing string config value."""
load_config = get_load_config_module()
cfg = {'system': {'name': 'default'}}
env = {'SYSTEM__NAME': 'custom_name'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['system']['name'] == 'custom_name'
def test_override_int_value(self):
"""Test overriding an int value with proper conversion."""
load_config = get_load_config_module()
cfg = {'concurrency': {'pipeline': 5}}
env = {'CONCURRENCY__PIPELINE': '10'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['concurrency']['pipeline'] == 10
assert isinstance(result['concurrency']['pipeline'], int)
def test_override_int_value_invalid_conversion(self):
"""Test that invalid int conversion keeps string value."""
load_config = get_load_config_module()
cfg = {'concurrency': {'pipeline': 5}}
env = {'CONCURRENCY__PIPELINE': 'not_a_number'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
# Falls back to string when conversion fails
assert result['concurrency']['pipeline'] == 'not_a_number'
def test_override_bool_value_true(self):
"""Test overriding bool value with 'true' string."""
load_config = get_load_config_module()
cfg = {'system': {'enable': False}}
env = {'SYSTEM__ENABLE': 'true'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['system']['enable'] is True
def test_override_bool_value_false(self):
"""Test overriding bool value with 'false' string."""
load_config = get_load_config_module()
cfg = {'system': {'enable': True}}
env = {'SYSTEM__ENABLE': 'false'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['system']['enable'] is False
def test_override_bool_value_various_true_forms(self):
"""Test that '1', 'yes', 'on' are treated as true."""
load_config = get_load_config_module()
cfg = {'system': {'flag': False}}
for true_val in ['1', 'yes', 'on', 'TRUE']:
env = {'SYSTEM__FLAG': true_val}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg.copy())
assert result['system']['flag'] is True
def test_override_float_value(self):
"""Test overriding float value with proper conversion."""
load_config = get_load_config_module()
cfg = {'system': {'timeout': 1.5}}
env = {'SYSTEM__TIMEOUT': '2.5'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['system']['timeout'] == 2.5
assert isinstance(result['system']['timeout'], float)
def test_override_list_value(self):
"""Test that comma-separated string converts to list."""
load_config = get_load_config_module()
cfg = {'system': {'disabled_adapters': ['adapter1']}}
env = {'SYSTEM__DISABLED_ADAPTERS': 'aiocqhttp,dingtalk,telegram'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['system']['disabled_adapters'] == ['aiocqhttp', 'dingtalk', 'telegram']
def test_override_list_value_empty_items(self):
"""Test that empty items in comma-separated list are filtered."""
load_config = get_load_config_module()
cfg = {'system': {'disabled_adapters': []}}
env = {'SYSTEM__DISABLED_ADAPTERS': 'a,,b,,,c'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
# Empty items should be filtered out
assert result['system']['disabled_adapters'] == ['a', 'b', 'c']
def test_skip_dict_type_override(self):
"""Test that dict type values are skipped."""
load_config = get_load_config_module()
cfg = {'plugin': {'settings': {'nested': 'value'}}}
env = {'PLUGIN__SETTINGS': 'should_not_apply'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
# Dict type should not be overridden
assert result['plugin']['settings'] == {'nested': 'value'}
def test_create_new_key_when_missing(self):
"""Test that missing keys are created as strings."""
load_config = get_load_config_module()
cfg = {'system': {}}
env = {'SYSTEM__NEW_KEY': 'new_value'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['system']['new_key'] == 'new_value'
def test_create_nested_path(self):
"""Test that intermediate dict is created for nested path."""
load_config = get_load_config_module()
cfg = {}
env = {'NEW__SECTION__KEY': 'value'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['new']['section']['key'] == 'value'
def test_skip_non_uppercase_env_vars(self):
"""Test that non-uppercase env vars are skipped."""
load_config = get_load_config_module()
cfg = {'system': {'name': 'default'}}
env = {'system__name': 'should_not_apply'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['system']['name'] == 'default'
def test_skip_env_vars_without_double_underscore(self):
"""Test that env vars without __ are skipped."""
load_config = get_load_config_module()
cfg = {'system': {'name': 'default'}}
env = {'SYSTEMNAME': 'should_not_apply'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['system']['name'] == 'default'
def test_nested_config_path(self):
"""Test overriding deeply nested config."""
load_config = get_load_config_module()
cfg = {'level1': {'level2': {'level3': 'original'}}}
env = {'LEVEL1__LEVEL2__LEVEL3': 'overridden'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['level1']['level2']['level3'] == 'overridden'
def test_non_dict_current_breaks(self):
"""Test that path navigation stops when current is not dict."""
load_config = get_load_config_module()
cfg = {'system': 'not_a_dict'}
env = {'SYSTEM__NAME': 'should_not_apply'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
# Should remain unchanged since 'system' is not a dict
assert result == {'system': 'not_a_dict'}
def test_empty_config(self):
"""Test that empty config dict is handled."""
load_config = get_load_config_module()
cfg = {}
env = {'SOME__KEY': 'value'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['some']['key'] == 'value'
def test_no_matching_env_vars(self):
"""Test that config is unchanged when no matching env vars."""
load_config = get_load_config_module()
cfg = {'system': {'name': 'default'}}
env = {'OTHER_VAR': 'value'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result == cfg
def test_multiple_env_vars_override(self):
"""Test multiple env vars applied in order."""
load_config = get_load_config_module()
cfg = {
'system': {'name': 'default', 'enable': True},
'concurrency': {'pipeline': 5}
}
env = {
'SYSTEM__NAME': 'custom',
'SYSTEM__ENABLE': 'false',
'CONCURRENCY__PIPELINE': '10'
}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['system']['name'] == 'custom'
assert result['system']['enable'] is False
assert result['concurrency']['pipeline'] == 10
def test_webhook_prefix_override(self):
"""Test overriding webhook_prefix via environment variable."""
load_config = get_load_config_module()
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
env = {'API__WEBHOOK_PREFIX': 'https://example.com:8080'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['api']['webhook_prefix'] == 'https://example.com:8080'
def test_extra_webhook_prefix_override(self):
"""Test overriding extra_webhook_prefix via environment variable."""
load_config = get_load_config_module()
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
env = {'API__EXTRA_WEBHOOK_PREFIX': 'https://extra.example.com'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['api']['extra_webhook_prefix'] == 'https://extra.example.com'
+238
View File
@@ -0,0 +1,238 @@
"""Tests for core migration registration and abstract classes."""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from tests.utils.import_isolation import isolated_sys_modules
class TestMigrationClassDecorator:
"""Tests for @migration_class decorator."""
def _make_migration_import_mocks(self):
"""Create mocks for migration import."""
return {
'langbot.pkg.core.app': MagicMock(),
}
def test_migration_class_registers_migration(self):
"""@migration_class registers migration in preregistered_migrations."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import migration_class, preregistered_migrations
# Clear for clean test
preregistered_migrations.clear()
@migration_class('test-migration', 1)
class TestMigration:
pass
assert len(preregistered_migrations) == 1
assert preregistered_migrations[0] == TestMigration
def test_migration_class_sets_name_attribute(self):
"""@migration_class sets name attribute on class."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import migration_class
@migration_class('test-migration', 1)
class TestMigration:
pass
assert TestMigration.name == 'test-migration'
def test_migration_class_sets_number_attribute(self):
"""@migration_class sets number attribute on class."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import migration_class
@migration_class('test-migration', 42)
class TestMigration:
pass
assert TestMigration.number == 42
def test_migration_class_returns_original_class(self):
"""@migration_class returns the original class."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import migration_class
@migration_class('test', 1)
class TestMigration:
custom_attr = 'value'
assert TestMigration.custom_attr == 'value'
def test_migration_class_multiple_migrations(self):
"""Multiple migrations can be registered."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import migration_class, preregistered_migrations
preregistered_migrations.clear()
@migration_class('migration1', 1)
class Migration1:
pass
@migration_class('migration2', 2)
class Migration2:
pass
assert len(preregistered_migrations) == 2
assert preregistered_migrations[0] == Migration1
assert preregistered_migrations[1] == Migration2
class TestMigrationAbstractClass:
"""Tests for Migration abstract class."""
def _make_migration_import_mocks(self):
return {'langbot.pkg.core.app': MagicMock()}
def test_migration_is_abstract(self):
"""Migration is abstract and cannot be instantiated directly."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import Migration
with pytest.raises(TypeError):
Migration(MagicMock())
def test_migration_requires_need_migrate_method(self):
"""Subclass must implement need_migrate method."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import Migration
class IncompleteMigration(Migration):
async def run(self):
pass
with pytest.raises(TypeError):
IncompleteMigration(MagicMock())
def test_migration_requires_run_method(self):
"""Subclass must implement run method."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import Migration
class IncompleteMigration(Migration):
async def need_migrate(self) -> bool:
return False
with pytest.raises(TypeError):
IncompleteMigration(MagicMock())
def test_migration_subclass_works(self):
"""Complete subclass can be instantiated."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import Migration
class CompleteMigration(Migration):
async def need_migrate(self) -> bool:
return True
async def run(self):
pass
mock_ap = MagicMock()
migration = CompleteMigration(mock_ap)
assert migration.ap == mock_ap
def test_migration_stores_app_reference(self):
"""Migration stores ap reference in __init__."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import Migration
class TestMigration(Migration):
async def need_migrate(self) -> bool:
return False
async def run(self):
pass
mock_ap = MagicMock()
migration = TestMigration(mock_ap)
assert migration.ap is mock_ap
@pytest.mark.asyncio
async def test_migration_need_migrate_returns_bool(self):
"""need_migrate must return bool."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import Migration
class TestMigration(Migration):
async def need_migrate(self) -> bool:
return True
async def run(self):
pass
migration = TestMigration(MagicMock())
result = await migration.need_migrate()
assert isinstance(result, bool)
assert result == True
class TestPreregisteredMigrations:
"""Tests for preregistered_migrations global registry."""
def _make_migration_import_mocks(self):
return {'langbot.pkg.core.app': MagicMock()}
def test_preregistered_migrations_is_list(self):
"""preregistered_migrations is a list."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import preregistered_migrations
assert isinstance(preregistered_migrations, list)
def test_preregistered_migrations_order(self):
"""Migrations are registered in order of decoration."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import migration_class, preregistered_migrations
preregistered_migrations.clear()
@migration_class('first', 1)
class First:
pass
@migration_class('second', 2)
class Second:
pass
@migration_class('third', 3)
class Third:
pass
# Order should match decoration order
assert preregistered_migrations[0].number == 1
assert preregistered_migrations[1].number == 2
assert preregistered_migrations[2].number == 3
+178
View File
@@ -0,0 +1,178 @@
"""Tests for core boot stage registration and abstract classes."""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from tests.utils.import_isolation import isolated_sys_modules
class TestStageClassDecorator:
"""Tests for @stage_class decorator."""
def _make_stage_import_mocks(self):
"""Create mocks for stage import."""
return {
'langbot.pkg.core.app': MagicMock(),
}
def test_stage_class_registers_stage(self):
"""@stage_class registers stage in preregistered_stages."""
mocks = self._make_stage_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import stage_class, preregistered_stages
# Clear for clean test
preregistered_stages.clear()
@stage_class('TestStage')
class TestStage:
pass
assert 'TestStage' in preregistered_stages
assert preregistered_stages['TestStage'] == TestStage
def test_stage_class_returns_original_class(self):
"""@stage_class returns the original class unchanged."""
mocks = self._make_stage_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import stage_class
@stage_class('TestStage')
class TestStage:
value = 42
# Class attributes should be preserved
assert TestStage.value == 42
def test_stage_class_multiple_stages(self):
"""Multiple stages can be registered."""
mocks = self._make_stage_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import stage_class, preregistered_stages
preregistered_stages.clear()
@stage_class('Stage1')
class Stage1:
pass
@stage_class('Stage2')
class Stage2:
pass
assert len(preregistered_stages) == 2
assert preregistered_stages['Stage1'] == Stage1
assert preregistered_stages['Stage2'] == Stage2
class TestBootingStageAbstract:
"""Tests for BootingStage abstract class."""
def _make_stage_import_mocks(self):
return {'langbot.pkg.core.app': MagicMock()}
def test_booting_stage_is_abstract(self):
"""BootingStage is abstract and cannot be instantiated directly."""
mocks = self._make_stage_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import BootingStage
with pytest.raises(TypeError):
BootingStage()
def test_booting_stage_requires_run_method(self):
"""Subclass must implement run method."""
mocks = self._make_stage_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import BootingStage
class IncompleteStage(BootingStage):
pass
with pytest.raises(TypeError):
IncompleteStage()
def test_booting_stage_subclass_works(self):
"""Complete subclass can be instantiated."""
mocks = self._make_stage_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import BootingStage
class CompleteStage(BootingStage):
name = 'CompleteStage'
async def run(self, ap):
pass
stage = CompleteStage()
assert stage.name == 'CompleteStage'
def test_booting_stage_name_attribute(self):
"""BootingStage has name attribute (None by default in abstract)."""
mocks = self._make_stage_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import BootingStage
# Abstract class has name attribute defined as None
assert hasattr(BootingStage, 'name')
@pytest.mark.asyncio
async def test_booting_stage_run_signature(self):
"""run method receives Application parameter."""
mocks = self._make_stage_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import BootingStage
class TestStage(BootingStage):
name = 'TestStage'
async def run(self, ap):
self.ap_received = ap
stage = TestStage()
mock_ap = MagicMock()
await stage.run(mock_ap)
assert stage.ap_received == mock_ap
class TestPreregisteredStages:
"""Tests for preregistered_stages global registry."""
def _make_stage_import_mocks(self):
return {'langbot.pkg.core.app': MagicMock()}
def test_preregistered_stages_is_dict(self):
"""preregistered_stages is a dictionary."""
mocks = self._make_stage_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import preregistered_stages
assert isinstance(preregistered_stages, dict)
def test_preregistered_stages_key_is_string(self):
"""Registry keys are stage names (strings)."""
mocks = self._make_stage_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import stage_class, preregistered_stages
preregistered_stages.clear()
@stage_class('MyStage')
class MyStage:
pass
for key in preregistered_stages:
assert isinstance(key, str)
+506
View File
@@ -0,0 +1,506 @@
"""Unit tests for core TaskContext, TaskWrapper, and AsyncTaskManager.
Tests cover:
- TaskContext initialization, state tracking, serialization
- TaskWrapper ID generation, to_dict serialization
- AsyncTaskManager task creation, stats, pruning
Note: Uses import_isolation to break circular import chains.
"""
from __future__ import annotations
import pytest
import asyncio
import sys
from unittest.mock import Mock, MagicMock
from contextlib import contextmanager
from typing import Generator
class MockLifecycleControlScopeEnum:
"""Mock enum value for LifecycleControlScope with .value attribute."""
def __init__(self, value: str):
self.value = value
def __repr__(self):
return f"LifecycleControlScope.{self.value.upper()}"
class MockLifecycleControlScope:
"""Mock enum for LifecycleControlScope."""
APPLICATION = MockLifecycleControlScopeEnum('application')
PLATFORM = MockLifecycleControlScopeEnum('platform')
PIPELINE = MockLifecycleControlScopeEnum('pipeline')
PLUGIN = MockLifecycleControlScopeEnum('plugin')
@contextmanager
def isolated_taskmgr_import() -> Generator[None, None, None]:
"""Context manager to isolate circular imports for taskmgr testing."""
# Mock modules that cause circular imports
mock_entities = MagicMock()
mock_entities.LifecycleControlScope = MockLifecycleControlScope
mock_app = MagicMock()
mock_importutil = MagicMock()
mock_importutil.import_modules_in_pkg = lambda pkg: None
mock_importutil.import_modules_in_pkgs = lambda pkgs: None
mock_http_controller = MagicMock()
mock_rag_mgr = MagicMock()
mocks = {
'langbot.pkg.core.entities': mock_entities,
'langbot.pkg.core.app': mock_app,
'langbot.pkg.api.http.controller.main': mock_http_controller,
'langbot.pkg.rag.knowledge.kbmgr': mock_rag_mgr,
'langbot.pkg.utils.importutil': mock_importutil,
}
# Save original state
saved = {}
for name in mocks:
if name in sys.modules:
saved[name] = sys.modules[name]
# Clear taskmgr to force re-import
taskmgr_name = 'langbot.pkg.core.taskmgr'
if taskmgr_name in sys.modules:
saved[taskmgr_name] = sys.modules[taskmgr_name]
try:
# Apply mocks
for name, module in mocks.items():
sys.modules[name] = module
# Clear taskmgr
sys.modules.pop(taskmgr_name, None)
yield
finally:
# Restore
for name in mocks:
if name in saved:
sys.modules[name] = saved[name]
else:
sys.modules.pop(name, None)
if taskmgr_name in saved:
sys.modules[taskmgr_name] = saved[taskmgr_name]
else:
sys.modules.pop(taskmgr_name, None)
def get_taskmgr_classes():
"""Get TaskContext, TaskWrapper, AsyncTaskManager classes."""
with isolated_taskmgr_import():
from langbot.pkg.core.taskmgr import TaskContext, TaskWrapper, AsyncTaskManager
return TaskContext, TaskWrapper, AsyncTaskManager
def create_mock_app():
"""Create a mock Application for testing."""
mock_app = Mock()
mock_app.event_loop = asyncio.get_running_loop()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {
'system': {
'task_retention': {
'completed_limit': 200,
}
}
}
return mock_app
class TestTaskContext:
"""Tests for TaskContext class."""
def test_init_default_values(self):
"""Test that TaskContext initializes with default values."""
TaskContext, _, _ = get_taskmgr_classes()
ctx = TaskContext()
assert ctx.current_action == 'default'
assert ctx.log == ''
assert ctx.metadata == {}
def test_set_current_action(self):
"""Test setting current action."""
TaskContext, _, _ = get_taskmgr_classes()
ctx = TaskContext()
ctx.set_current_action('installing_plugin')
assert ctx.current_action == 'installing_plugin'
def test_trace_without_action(self):
"""Test trace method without action override."""
TaskContext, _, _ = get_taskmgr_classes()
ctx = TaskContext()
ctx.trace('Starting process')
assert 'Starting process' in ctx.log
assert ctx.current_action == 'default'
def test_trace_with_action_override(self):
"""Test trace method with action override."""
TaskContext, _, _ = get_taskmgr_classes()
ctx = TaskContext()
ctx.trace('Downloading', action='download')
assert 'Downloading' in ctx.log
assert ctx.current_action == 'download'
def test_trace_accumulates_logs(self):
"""Test that trace accumulates log entries."""
TaskContext, _, _ = get_taskmgr_classes()
ctx = TaskContext()
ctx.trace('Step 1')
ctx.trace('Step 2')
ctx.trace('Step 3')
assert 'Step 1' in ctx.log
assert 'Step 2' in ctx.log
assert 'Step 3' in ctx.log
# Each trace adds a newline
assert ctx.log.count('\n') == 3
def test_to_dict_serialization(self):
"""Test to_dict serialization."""
TaskContext, _, _ = get_taskmgr_classes()
ctx = TaskContext()
ctx.set_current_action('test_action')
ctx.trace('Test message')
ctx.metadata['key'] = 'value'
result = ctx.to_dict()
assert result['current_action'] == 'test_action'
assert 'Test message' in result['log']
assert result['metadata'] == {'key': 'value'}
def test_static_new_factory(self):
"""Test TaskContext.new() factory method."""
TaskContext, _, _ = get_taskmgr_classes()
ctx = TaskContext.new()
assert isinstance(ctx, TaskContext)
assert ctx.current_action == 'default'
def test_static_placeholder_singleton(self):
"""Test TaskContext.placeholder() returns singleton."""
with isolated_taskmgr_import():
from langbot.pkg.core.taskmgr import TaskContext
# Reset global placeholder
import langbot.pkg.core.taskmgr as taskmgr_module
taskmgr_module.placeholder_context = None
ctx1 = TaskContext.placeholder()
ctx2 = TaskContext.placeholder()
assert ctx1 is ctx2
def test_metadata_is_mutable_dict(self):
"""Test that metadata is a mutable dict."""
TaskContext, _, _ = get_taskmgr_classes()
ctx = TaskContext()
ctx.metadata['count'] = 5
ctx.metadata['items'] = ['a', 'b', 'c']
assert ctx.metadata['count'] == 5
assert len(ctx.metadata['items']) == 3
class TestTaskWrapper:
"""Tests for TaskWrapper class."""
@pytest.mark.asyncio
async def test_id_auto_increment(self):
"""Test that task IDs auto-increment."""
TaskContext, TaskWrapper, _ = get_taskmgr_classes()
# Reset ID index
TaskWrapper._id_index = 0
mock_app = create_mock_app()
async def dummy_coro():
await asyncio.sleep(0.01)
return 'done'
wrapper1 = TaskWrapper(mock_app, dummy_coro())
wrapper2 = TaskWrapper(mock_app, dummy_coro())
assert wrapper1.id == 0
assert wrapper2.id == 1
# Clean up
wrapper1.cancel()
wrapper2.cancel()
@pytest.mark.asyncio
async def test_default_task_type_and_kind(self):
"""Test default task_type and kind values."""
_, TaskWrapper, _ = get_taskmgr_classes()
mock_app = create_mock_app()
async def dummy_coro():
return 'done'
wrapper = TaskWrapper(mock_app, dummy_coro())
assert wrapper.task_type == 'system'
assert wrapper.kind == 'system_task'
wrapper.cancel()
@pytest.mark.asyncio
async def test_to_dict_serialization(self):
"""Test TaskWrapper.to_dict serialization."""
_, TaskWrapper, _ = get_taskmgr_classes()
mock_app = create_mock_app()
async def immediate_coro():
return 'result'
wrapper = TaskWrapper(
mock_app, immediate_coro(),
name='test_task',
label='Test Task',
)
# Wait for task to complete
await wrapper.task
result = wrapper.to_dict()
assert result['name'] == 'test_task'
assert result['label'] == 'Test Task'
assert result['task_type'] == 'system'
assert result['runtime']['done'] == True
assert result['runtime']['result'] == 'result'
@pytest.mark.asyncio
async def test_to_dict_with_exception(self):
"""Test TaskWrapper.to_dict when task has exception."""
_, TaskWrapper, _ = get_taskmgr_classes()
mock_app = create_mock_app()
async def failing_coro():
raise ValueError('Test error')
wrapper = TaskWrapper(mock_app, failing_coro())
# Wait for task to complete
try:
await wrapper.task
except ValueError:
pass
result = wrapper.to_dict()
assert result['runtime']['done'] == True
assert result['runtime']['exception'] == 'Test error'
assert 'exception_traceback' in result['runtime']
@pytest.mark.asyncio
async def test_cancel_task(self):
"""Test cancel method cancels the asyncio task."""
_, TaskWrapper, _ = get_taskmgr_classes()
mock_app = create_mock_app()
async def long_coro():
await asyncio.sleep(10)
return 'done'
wrapper = TaskWrapper(mock_app, long_coro())
# Task should be running
assert not wrapper.task.done()
wrapper.cancel()
# Give it a moment to be cancelled
await asyncio.sleep(0.01)
assert wrapper.task.done()
assert wrapper.task.cancelled()
class TestAsyncTaskManager:
"""Tests for AsyncTaskManager class."""
@pytest.mark.asyncio
async def test_create_task_adds_to_list(self):
"""Test that create_task adds task to tasks list."""
_, _, AsyncTaskManager = get_taskmgr_classes()
mock_app = create_mock_app()
manager = AsyncTaskManager(mock_app)
async def dummy_coro():
await asyncio.sleep(0.01)
return 'done'
wrapper = manager.create_task(dummy_coro())
assert wrapper in manager.tasks
assert len(manager.tasks) == 1
wrapper.cancel()
@pytest.mark.asyncio
async def test_get_stats_counts_correctly(self):
"""Test get_stats returns correct counts."""
_, _, AsyncTaskManager = get_taskmgr_classes()
mock_app = create_mock_app()
manager = AsyncTaskManager(mock_app)
async def immediate_coro():
return 'done'
async def delayed_coro():
await asyncio.sleep(0.1)
return 'done'
# Create tasks
w1 = manager.create_task(immediate_coro())
w2 = manager.create_task(delayed_coro())
# Wait for first to complete
await w1.task
stats = manager.get_stats()
assert stats['total'] == 2
assert stats['completed'] == 1
assert stats['running'] == 1
w2.cancel()
@pytest.mark.asyncio
async def test_get_tasks_dict_filters_by_type(self):
"""Test get_tasks_dict filters by type."""
_, _, AsyncTaskManager = get_taskmgr_classes()
mock_app = create_mock_app()
manager = AsyncTaskManager(mock_app)
async def dummy_coro():
await asyncio.sleep(0.01)
# Create system and user tasks
w1 = manager.create_task(dummy_coro(), task_type='system')
w2 = manager.create_task(dummy_coro(), task_type='user')
w3 = manager.create_task(dummy_coro(), task_type='user')
result = manager.get_tasks_dict(type='user')
assert len(result['tasks']) == 2
for t in result['tasks']:
assert t['task_type'] == 'user'
w1.cancel()
w2.cancel()
w3.cancel()
@pytest.mark.asyncio
async def test_cancel_by_scope(self):
"""Test cancel_by_scope cancels matching tasks."""
_, _, AsyncTaskManager = get_taskmgr_classes()
mock_app = create_mock_app()
manager = AsyncTaskManager(mock_app)
async def long_coro():
await asyncio.sleep(10)
# Create task with APPLICATION scope
w1 = manager.create_task(
long_coro(),
scopes=[MockLifecycleControlScope.APPLICATION]
)
# Create task with different scope
w2 = manager.create_task(
long_coro(),
scopes=[MockLifecycleControlScope.PIPELINE]
)
manager.cancel_by_scope(MockLifecycleControlScope.APPLICATION)
await asyncio.sleep(0.01)
assert w1.task.cancelled() or w1.task.done()
assert not w2.task.done()
w2.cancel()
@pytest.mark.asyncio
async def test_cancel_task_by_id(self):
"""Test cancel_task cancels specific task by ID."""
_, _, AsyncTaskManager = get_taskmgr_classes()
mock_app = create_mock_app()
manager = AsyncTaskManager(mock_app)
async def long_coro():
await asyncio.sleep(10)
w1 = manager.create_task(long_coro())
w2 = manager.create_task(long_coro())
manager.cancel_task(w1.id)
await asyncio.sleep(0.01)
assert w1.task.done()
assert not w2.task.done()
w2.cancel()
@pytest.mark.asyncio
async def test_create_user_task_sets_user_type(self):
"""Test create_user_task sets task_type to 'user'."""
_, _, AsyncTaskManager = get_taskmgr_classes()
mock_app = create_mock_app()
manager = AsyncTaskManager(mock_app)
async def dummy_coro():
await asyncio.sleep(0.01)
wrapper = manager.create_user_task(dummy_coro())
assert wrapper.task_type == 'user'
wrapper.cancel()
@pytest.mark.asyncio
async def test_get_task_by_id(self):
"""Test get_task_by_id returns correct task."""
_, _, AsyncTaskManager = get_taskmgr_classes()
mock_app = create_mock_app()
manager = AsyncTaskManager(mock_app)
async def dummy_coro():
await asyncio.sleep(0.01)
w1 = manager.create_task(dummy_coro())
w2 = manager.create_task(dummy_coro())
found = manager.get_task_by_id(w1.id)
assert found is w1
not_found = manager.get_task_by_id(9999)
assert not_found is None
w1.cancel()
w2.cancel()
+191
View File
@@ -0,0 +1,191 @@
"""
Unit tests for discover engine utilities.
Tests I18nString, Metadata, and Component utilities.
"""
from __future__ import annotations
from langbot.pkg.discover.engine import I18nString, Metadata, Component
class TestI18nString:
"""Tests for I18nString Pydantic model."""
def test_create_with_english_only(self):
"""Create I18nString with only English."""
i18n = I18nString(en_US="Hello")
assert i18n.en_US == "Hello"
assert i18n.zh_Hans is None
def test_create_with_multiple_languages(self):
"""Create I18nString with multiple languages."""
i18n = I18nString(
en_US="Hello",
zh_Hans="你好",
zh_Hant="你好",
ja_JP="こんにちは",
)
assert i18n.en_US == "Hello"
assert i18n.zh_Hans == "你好"
assert i18n.zh_Hant == "你好"
assert i18n.ja_JP == "こんにちは"
def test_to_dict_with_english_only(self):
"""to_dict returns only non-None fields."""
i18n = I18nString(en_US="Hello")
result = i18n.to_dict()
assert result == {"en_US": "Hello"}
def test_to_dict_with_multiple_languages(self):
"""to_dict returns all non-None fields."""
i18n = I18nString(
en_US="Hello",
zh_Hans="你好",
)
result = i18n.to_dict()
assert result == {"en_US": "Hello", "zh_Hans": "你好"}
def test_to_dict_excludes_none(self):
"""to_dict excludes None values."""
i18n = I18nString(
en_US="Hello",
zh_Hans=None,
ja_JP="こんにちは",
)
result = i18n.to_dict()
assert "zh_Hans" not in result
assert "en_US" in result
assert "ja_JP" in result
def test_to_dict_all_languages(self):
"""to_dict with all supported languages."""
i18n = I18nString(
en_US="Hello",
zh_Hans="你好",
zh_Hant="你好",
ja_JP="こんにちは",
th_TH="สวัสดี",
vi_VN="Xin chào",
es_ES="Hola",
)
result = i18n.to_dict()
assert len(result) == 7
class TestMetadata:
"""Tests for Metadata Pydantic model."""
def test_create_minimal(self):
"""Create Metadata with required fields only."""
from langbot.pkg.discover.engine import I18nString
metadata = Metadata(
name="test-component",
label=I18nString(en_US="Test Component"),
)
assert metadata.name == "test-component"
assert metadata.label.en_US == "Test Component"
def test_create_with_all_fields(self):
"""Create Metadata with all optional fields."""
from langbot.pkg.discover.engine import I18nString
metadata = Metadata(
name="test-component",
label=I18nString(en_US="Test"),
description=I18nString(en_US="A test component"),
version="1.0.0",
icon="test-icon",
author="Test Author",
repository="https://github.com/test/repo",
)
assert metadata.version == "1.0.0"
assert metadata.icon == "test-icon"
assert metadata.author == "Test Author"
class TestComponentManifest:
"""Tests for Component manifest detection."""
def test_is_component_manifest_valid(self):
"""is_component_manifest returns True for valid manifest."""
manifest = {
'apiVersion': 'v1',
'kind': 'Component',
'metadata': {'name': 'test'},
'spec': {},
}
assert Component.is_component_manifest(manifest) is True
def test_is_component_manifest_missing_apiversion(self):
"""is_component_manifest returns False without apiVersion."""
manifest = {
'kind': 'Component',
'metadata': {'name': 'test'},
'spec': {},
}
assert Component.is_component_manifest(manifest) is False
def test_is_component_manifest_missing_kind(self):
"""is_component_manifest returns False without kind."""
manifest = {
'apiVersion': 'v1',
'metadata': {'name': 'test'},
'spec': {},
}
assert Component.is_component_manifest(manifest) is False
def test_is_component_manifest_missing_metadata(self):
"""is_component_manifest returns False without metadata."""
manifest = {
'apiVersion': 'v1',
'kind': 'Component',
'spec': {},
}
assert Component.is_component_manifest(manifest) is False
def test_is_component_manifest_missing_spec(self):
"""is_component_manifest returns False without spec."""
manifest = {
'apiVersion': 'v1',
'kind': 'Component',
'metadata': {'name': 'test'},
}
assert Component.is_component_manifest(manifest) is False
def test_is_component_manifest_empty(self):
"""is_component_manifest returns False for empty dict."""
manifest = {}
assert Component.is_component_manifest(manifest) is False
def test_is_component_manifest_extra_fields_ok(self):
"""is_component_manifest accepts extra fields."""
manifest = {
'apiVersion': 'v1',
'kind': 'Component',
'metadata': {'name': 'test'},
'spec': {},
'extraField': 'ignored',
}
assert Component.is_component_manifest(manifest) is True
@@ -0,0 +1,201 @@
"""Unit tests for persistence database decorators.
Tests cover:
- manager_class decorator registration
- Class attribute setting
- preregistered_managers list population
Note: Uses import isolation to break circular import chains.
"""
from __future__ import annotations
import sys
from unittest.mock import Mock, MagicMock
from contextlib import contextmanager
from typing import Generator
@contextmanager
def isolated_database_import() -> Generator[None, None, None]:
"""Context manager to isolate circular imports for database testing."""
# Mock modules that cause circular imports
mock_app = MagicMock()
mock_importutil = MagicMock()
mock_importutil.import_modules_in_pkg = lambda pkg: None
mock_importutil.import_modules_in_pkgs = lambda pkgs: None
mock_mgr = MagicMock()
mocks = {
'langbot.pkg.core.app': mock_app,
'langbot.pkg.utils.importutil': mock_importutil,
'langbot.pkg.persistence.mgr': mock_mgr,
}
# Save original state
saved = {}
for name in mocks:
if name in sys.modules:
saved[name] = sys.modules[name]
# Clear database module to force re-import
database_name = 'langbot.pkg.persistence.database'
if database_name in sys.modules:
saved[database_name] = sys.modules[database_name]
# Also clear databases submodules
for sub in ['sqlite', 'postgresql']:
full_name = f'langbot.pkg.persistence.databases.{sub}'
if full_name in sys.modules:
saved[full_name] = sys.modules[full_name]
try:
# Apply mocks
for name, module in mocks.items():
sys.modules[name] = module
# Clear database and submodules
sys.modules.pop(database_name, None)
for sub in ['sqlite', 'postgresql']:
sys.modules.pop(f'langbot.pkg.persistence.databases.{sub}', None)
yield
finally:
# Restore
for name in mocks:
if name in saved:
sys.modules[name] = saved[name]
else:
sys.modules.pop(name, None)
if database_name in saved:
sys.modules[database_name] = saved[database_name]
else:
sys.modules.pop(database_name, None)
for sub in ['sqlite', 'postgresql']:
full_name = f'langbot.pkg.persistence.databases.{sub}'
if full_name in saved:
sys.modules[full_name] = saved[full_name]
else:
sys.modules.pop(full_name, None)
def get_database_module():
"""Get database module with import isolation."""
with isolated_database_import():
from langbot.pkg.persistence import database
return database
class TestManagerClassDecorator:
"""Tests for manager_class decorator."""
def test_decorator_sets_name_attribute(self):
"""Test that decorator sets the 'name' attribute on class."""
database = get_database_module()
# Clear preregistered_managers for this test
database.preregistered_managers.clear()
@database.manager_class('test_db')
class TestManager(database.BaseDatabaseManager):
async def initialize(self):
pass
assert TestManager.name == 'test_db'
def test_decorator_adds_to_preregistered_list(self):
"""Test that decorator adds class to preregistered_managers."""
database = get_database_module()
# Clear preregistered_managers for this test
database.preregistered_managers.clear()
@database.manager_class('test_db2')
class TestManager2(database.BaseDatabaseManager):
async def initialize(self):
pass
assert len(database.preregistered_managers) == 1
assert database.preregistered_managers[0] == TestManager2
def test_decorator_returns_original_class(self):
"""Test that decorator returns the same class."""
database = get_database_module()
database.preregistered_managers.clear()
class OriginalClass(database.BaseDatabaseManager):
async def initialize(self):
pass
decorated = database.manager_class('test_db3')(OriginalClass)
assert decorated is OriginalClass
def test_multiple_decorators_register_separately(self):
"""Test that multiple decorated classes register separately."""
database = get_database_module()
database.preregistered_managers.clear()
@database.manager_class('db_a')
class ManagerA(database.BaseDatabaseManager):
async def initialize(self):
pass
@database.manager_class('db_b')
class ManagerB(database.BaseDatabaseManager):
async def initialize(self):
pass
assert len(database.preregistered_managers) == 2
assert database.preregistered_managers[0].name == 'db_a'
assert database.preregistered_managers[1].name == 'db_b'
def test_base_database_manager_has_name_annotation(self):
"""Test that BaseDatabaseManager has name as class annotation."""
database = get_database_module()
# BaseDatabaseManager has name annotation (type hint)
# Check __annotations__ for the type hint
assert 'name' in database.BaseDatabaseManager.__annotations__
def test_decorated_class_inherits_from_base(self):
"""Test that decorated class properly inherits BaseDatabaseManager."""
database = get_database_module()
database.preregistered_managers.clear()
@database.manager_class('test_inherit')
class TestChild(database.BaseDatabaseManager):
async def initialize(self):
pass
assert issubclass(TestChild, database.BaseDatabaseManager)
# Has abstract method requirement satisfied
assert hasattr(TestChild, 'initialize')
def test_decorator_preserves_class_methods(self):
"""Test that decorator preserves existing class methods."""
database = get_database_module()
database.preregistered_managers.clear()
@database.manager_class('preserve_test')
class ManagerWithMethods(database.BaseDatabaseManager):
custom_attr = 'test_value'
async def initialize(self):
pass
def custom_method(self):
return self.custom_attr
assert ManagerWithMethods.custom_attr == 'test_value'
# Create instance to test method (with mock app)
mock_app = Mock()
instance = ManagerWithMethods(mock_app)
assert instance.custom_method() == 'test_value'
@@ -0,0 +1,155 @@
"""Unit tests for persistence manager methods.
Tests cover:
- execute_async() with mock database
- get_db_engine() with mock database manager
"""
from __future__ import annotations
import pytest
from unittest.mock import Mock, AsyncMock, MagicMock
from importlib import import_module
import sqlalchemy
def get_persistence_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.persistence.mgr')
class TestExecuteAsync:
"""Tests for execute_async method."""
@pytest.mark.asyncio
async def test_execute_async_calls_engine_execute(self):
"""Test that execute_async calls engine execute."""
persistence = get_persistence_module()
mock_app = Mock()
mock_app.persistence_mgr = None
mgr = persistence.PersistenceManager(mock_app)
# Mock database manager with async engine
mock_engine = MagicMock()
mock_conn = AsyncMock()
mock_conn.execute = AsyncMock(return_value=Mock())
mock_conn.commit = AsyncMock()
# Setup the async context manager
async_cm = AsyncMock()
async_cm.__aenter__ = AsyncMock(return_value=mock_conn)
async_cm.__aexit__ = AsyncMock(return_value=None)
mock_engine.connect = Mock(return_value=async_cm)
mock_db = Mock()
mock_db.get_engine = Mock(return_value=mock_engine)
mgr.db = mock_db
# Execute a simple select
await mgr.execute_async(sqlalchemy.select(1))
mock_conn.execute.assert_called_once()
mock_conn.commit.assert_called_once()
@pytest.mark.asyncio
async def test_execute_async_returns_result(self):
"""Test that execute_async returns the result from execute.
NOTE: This test verifies the return value chain - that the result
from conn.execute() is properly returned by execute_async().
The mock verifies the value propagation, not the SQL execution.
For real SQL execution tests, see integration tests.
"""
persistence = get_persistence_module()
mock_app = Mock()
mgr = persistence.PersistenceManager(mock_app)
# Create a mock result with actual attributes to simulate real result
mock_result = Mock(name='query_result')
mock_result.scalar = Mock(return_value=1) # Simulate scalar() method
mock_result.scalars = Mock() # Simulate scalars() method
mock_engine = MagicMock()
mock_conn = AsyncMock()
mock_conn.execute = AsyncMock(return_value=mock_result)
mock_conn.commit = AsyncMock()
async_cm = AsyncMock()
async_cm.__aenter__ = AsyncMock(return_value=mock_conn)
async_cm.__aexit__ = AsyncMock(return_value=None)
mock_engine.connect = Mock(return_value=async_cm)
mock_db = Mock()
mock_db.get_engine = Mock(return_value=mock_engine)
mgr.db = mock_db
result = await mgr.execute_async(sqlalchemy.text("SELECT 1"))
# Verify result is the same object returned by execute
assert result is mock_result
# Verify result has expected methods (simulating real Result object)
assert hasattr(result, 'scalar')
assert result.scalar() == 1
class TestGetDbEngine:
"""Tests for get_db_engine method."""
def test_get_db_engine_returns_engine_from_db_manager(self):
"""Test that get_db_engine returns engine from db manager."""
persistence = get_persistence_module()
mock_app = Mock()
mgr = persistence.PersistenceManager(mock_app)
mock_engine = Mock(name='engine')
mock_db = Mock()
mock_db.get_engine = Mock(return_value=mock_engine)
mgr.db = mock_db
engine = mgr.get_db_engine()
assert engine == mock_engine
mock_db.get_engine.assert_called_once()
def test_get_db_engine_without_db_set_raises(self):
"""Test that get_db_engine raises when db is not set."""
persistence = get_persistence_module()
mock_app = Mock()
mgr = persistence.PersistenceManager(mock_app)
# db is not initialized
mgr.db = None
with pytest.raises(AttributeError):
mgr.get_db_engine()
class TestSerializeModelEdgeCases:
"""Tests for serialize_model edge cases."""
def test_serialize_model_with_all_columns_masked(self):
"""Test serialize_model when all columns are masked."""
persistence = get_persistence_module()
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class SimpleModel(Base):
__tablename__ = 'simple'
id = Column(Integer, primary_key=True)
name = Column(String(50))
mock_app = Mock()
mgr = persistence.PersistenceManager(mock_app)
instance = SimpleModel(id=1, name='test')
result = mgr.serialize_model(SimpleModel, instance, masked_columns=['id', 'name'])
# Result should be empty dict when all columns masked
assert result == {}
@@ -0,0 +1,128 @@
"""Unit tests for persistence serialize_model function.
Tests cover:
- serialize_model() with various column types
- datetime conversion to isoformat
- masked_columns exclusion
"""
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
def get_persistence_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.persistence.mgr')
# Create a simple test model
Base = declarative_base()
class TestModel(Base):
__tablename__ = 'test_model'
id = Column(Integer, primary_key=True)
name = Column(String(50))
created_at = Column(DateTime)
updated_at = Column(DateTime, nullable=True)
class TestSerializeModel:
"""Tests for serialize_model method."""
def test_serialize_string_and_int_columns(self):
"""Test that string and int columns are serialized directly."""
persistence = get_persistence_module()
# Create a mock persistence manager
mock_app = Mock()
mock_app.persistence_mgr = None
mgr = persistence.PersistenceManager(mock_app)
# Create test model instance
instance = TestModel(id=1, name='test_name', created_at=datetime.datetime(2024, 1, 15, 10, 30, 0))
result = mgr.serialize_model(TestModel, instance)
assert result['id'] == 1
assert result['name'] == 'test_name'
def test_serialize_datetime_to_isoformat(self):
"""Test that datetime columns are converted to isoformat string."""
persistence = get_persistence_module()
mock_app = Mock()
mgr = persistence.PersistenceManager(mock_app)
dt = datetime.datetime(2024, 1, 15, 10, 30, 45)
instance = TestModel(id=1, name='test', created_at=dt)
result = mgr.serialize_model(TestModel, instance)
assert result['created_at'] == '2024-01-15T10:30:45'
assert isinstance(result['created_at'], str)
def test_serialize_datetime_with_timezone(self):
"""Test datetime with timezone conversion."""
persistence = get_persistence_module()
mock_app = Mock()
mgr = persistence.PersistenceManager(mock_app)
# datetime with timezone
dt = datetime.datetime(2024, 1, 15, 10, 30, 45, tzinfo=datetime.timezone.utc)
instance = TestModel(id=1, name='test', created_at=dt)
result = mgr.serialize_model(TestModel, instance)
assert '2024-01-15' in result['created_at']
assert isinstance(result['created_at'], str)
def test_serialize_none_datetime(self):
"""Test that None datetime column is serialized as None."""
persistence = get_persistence_module()
mock_app = Mock()
mgr = persistence.PersistenceManager(mock_app)
instance = TestModel(id=1, name='test', created_at=datetime.datetime.now(), updated_at=None)
result = mgr.serialize_model(TestModel, instance)
# None datetime should be None (not converted to isoformat)
assert result['updated_at'] is None
def test_masked_columns_excluded(self):
"""Test that masked columns are excluded from output."""
persistence = get_persistence_module()
mock_app = Mock()
mgr = persistence.PersistenceManager(mock_app)
instance = TestModel(id=1, name='secret_name', created_at=datetime.datetime.now())
result = mgr.serialize_model(TestModel, instance, masked_columns=['name'])
assert 'id' in result
assert 'created_at' in result
assert 'name' not in result
def test_masked_columns_multiple(self):
"""Test that multiple masked columns are excluded."""
persistence = get_persistence_module()
mock_app = Mock()
mgr = persistence.PersistenceManager(mock_app)
instance = TestModel(id=1, name='secret', created_at=datetime.datetime.now())
result = mgr.serialize_model(TestModel, instance, masked_columns=['id', 'name'])
assert 'id' not in result
assert 'name' not in result
assert 'created_at' in result
@@ -0,0 +1,637 @@
"""
Unit tests for MessageAggregator (aggregator) module.
Tests cover:
- Message buffering and merging
- Timer-based flush behavior
- MAX_BUFFER_MESSAGES limit
- Aggregation enabled/disabled
- Config delay clamping
"""
from __future__ import annotations
import pytest
import asyncio
from unittest.mock import Mock, AsyncMock
from importlib import import_module
from tests.factories import (
FakeApp,
text_chain,
friend_message_event,
mock_adapter,
)
import langbot_plugin.api.entities.builtin.provider.session as provider_session
def get_aggregator_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.pipeline.aggregator')
def make_aggregator_app():
"""Create a FakeApp with necessary mocks for aggregator tests."""
app = FakeApp()
# Ensure query_pool has add_query method
app.query_pool.add_query = AsyncMock()
# Add pipeline_mgr mock
app.pipeline_mgr = AsyncMock()
app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=None)
return app
class TestPendingMessage:
"""Tests for PendingMessage dataclass."""
def test_pending_message_creation(self):
"""PendingMessage should be created with correct fields."""
aggregator = get_aggregator_module()
chain = text_chain("hello")
event = friend_message_event(chain)
adapter = mock_adapter()
pending = aggregator.PendingMessage(
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_event=event,
message_chain=chain,
adapter=adapter,
pipeline_uuid='test-pipeline',
)
assert pending.bot_uuid == 'test-bot'
assert pending.launcher_type == provider_session.LauncherTypes.PERSON
assert pending.message_chain == chain
assert pending.timestamp is not None
class TestSessionBuffer:
"""Tests for SessionBuffer dataclass."""
def test_session_buffer_creation(self):
"""SessionBuffer should be created with correct fields."""
aggregator = get_aggregator_module()
buffer = aggregator.SessionBuffer(session_id='test-session')
assert buffer.session_id == 'test-session'
assert buffer.messages == []
assert buffer.timer_task is None
assert buffer.last_message_time is not None
def test_session_buffer_with_messages(self):
"""SessionBuffer should accept initial messages."""
aggregator = get_aggregator_module()
chain = text_chain("hello")
event = friend_message_event(chain)
adapter = mock_adapter()
pending = aggregator.PendingMessage(
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_event=event,
message_chain=chain,
adapter=adapter,
pipeline_uuid=None,
)
buffer = aggregator.SessionBuffer(
session_id='test-session',
messages=[pending],
)
assert len(buffer.messages) == 1
class TestMessageAggregatorInit:
"""Tests for MessageAggregator initialization."""
def test_aggregator_init(self):
"""MessageAggregator should initialize with correct fields."""
aggregator = get_aggregator_module()
app = make_aggregator_app()
agg = aggregator.MessageAggregator(app)
assert agg.ap == app
assert agg.buffers == {}
assert isinstance(agg.lock, asyncio.Lock)
class TestMessageAggregatorSessionId:
"""Tests for session ID generation."""
def test_session_id_format(self):
"""Session ID should be correctly formatted."""
aggregator = get_aggregator_module()
app = make_aggregator_app()
agg = aggregator.MessageAggregator(app)
session_id = agg._get_session_id(
bot_uuid='bot-123',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=45678,
)
assert session_id == 'bot-123:person:45678'
def test_session_id_different_launchers(self):
"""Different launcher types should produce different IDs."""
aggregator = get_aggregator_module()
app = make_aggregator_app()
agg = aggregator.MessageAggregator(app)
person_id = agg._get_session_id(
bot_uuid='bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=123,
)
group_id = agg._get_session_id(
bot_uuid='bot',
launcher_type=provider_session.LauncherTypes.GROUP,
launcher_id=123,
)
assert person_id != group_id
class TestMessageAggregatorConfig:
"""Tests for aggregation config retrieval."""
@pytest.mark.asyncio
async def test_config_none_pipeline(self):
"""None pipeline_uuid should return default config."""
aggregator = get_aggregator_module()
app = make_aggregator_app()
agg = aggregator.MessageAggregator(app)
enabled, delay = await agg._get_aggregation_config(None)
assert enabled == False
assert delay == 1.5
@pytest.mark.asyncio
async def test_config_pipeline_not_found(self):
"""Non-existent pipeline should return default config."""
aggregator = get_aggregator_module()
app = make_aggregator_app()
app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=None)
agg = aggregator.MessageAggregator(app)
enabled, delay = await agg._get_aggregation_config('unknown-pipeline')
assert enabled == False
assert delay == 1.5
@pytest.mark.asyncio
async def test_config_enabled(self):
"""Pipeline with enabled aggregation should return True."""
aggregator = get_aggregator_module()
app = make_aggregator_app()
mock_pipeline = Mock()
mock_pipeline.pipeline_entity = Mock()
mock_pipeline.pipeline_entity.config = {
'trigger': {
'message-aggregation': {
'enabled': True,
'delay': 2.0,
}
}
}
app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=mock_pipeline)
agg = aggregator.MessageAggregator(app)
enabled, delay = await agg._get_aggregation_config('test-pipeline')
assert enabled == True
assert delay == 2.0
@pytest.mark.asyncio
async def test_config_delay_clamped_low(self):
"""Delay below 1.0 should be clamped to 1.0."""
aggregator = get_aggregator_module()
app = make_aggregator_app()
mock_pipeline = Mock()
mock_pipeline.pipeline_entity = Mock()
mock_pipeline.pipeline_entity.config = {
'trigger': {
'message-aggregation': {
'enabled': True,
'delay': 0.5, # Below minimum
}
}
}
app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=mock_pipeline)
agg = aggregator.MessageAggregator(app)
enabled, delay = await agg._get_aggregation_config('test-pipeline')
assert delay == 1.0 # Clamped to minimum
@pytest.mark.asyncio
async def test_config_delay_clamped_high(self):
"""Delay above 10.0 should be clamped to 10.0."""
aggregator = get_aggregator_module()
app = make_aggregator_app()
mock_pipeline = Mock()
mock_pipeline.pipeline_entity = Mock()
mock_pipeline.pipeline_entity.config = {
'trigger': {
'message-aggregation': {
'enabled': True,
'delay': 15.0, # Above maximum
}
}
}
app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=mock_pipeline)
agg = aggregator.MessageAggregator(app)
enabled, delay = await agg._get_aggregation_config('test-pipeline')
assert delay == 10.0 # Clamped to maximum
@pytest.mark.asyncio
async def test_config_delay_invalid_type(self):
"""Invalid delay type should use default."""
aggregator = get_aggregator_module()
app = make_aggregator_app()
mock_pipeline = Mock()
mock_pipeline.pipeline_entity = Mock()
mock_pipeline.pipeline_entity.config = {
'trigger': {
'message-aggregation': {
'enabled': True,
'delay': 'invalid', # Not a number
}
}
}
app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=mock_pipeline)
agg = aggregator.MessageAggregator(app)
enabled, delay = await agg._get_aggregation_config('test-pipeline')
assert delay == 1.5 # Default
class TestMessageAggregatorAddMessage:
"""Tests for add_message behavior."""
@pytest.mark.asyncio
async def test_disabled_adds_to_query_pool(self):
"""Disabled aggregation should directly add to query_pool."""
aggregator = get_aggregator_module()
app = make_aggregator_app()
agg = aggregator.MessageAggregator(app)
chain = text_chain("hello")
event = friend_message_event(chain)
adapter = mock_adapter()
await agg.add_message(
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_event=event,
message_chain=chain,
adapter=adapter,
pipeline_uuid=None, # None -> disabled
)
# Should have called query_pool.add_query
assert app.query_pool.add_query.called
@pytest.mark.asyncio
async def test_enabled_buffers_message(self):
"""Enabled aggregation should buffer message."""
aggregator = get_aggregator_module()
app = make_aggregator_app()
mock_pipeline = Mock()
mock_pipeline.pipeline_entity = Mock()
mock_pipeline.pipeline_entity.config = {
'trigger': {
'message-aggregation': {
'enabled': True,
'delay': 2.0,
}
}
}
app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=mock_pipeline)
agg = aggregator.MessageAggregator(app)
chain = text_chain("hello")
event = friend_message_event(chain)
adapter = mock_adapter()
await agg.add_message(
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_event=event,
message_chain=chain,
adapter=adapter,
pipeline_uuid='test-pipeline',
)
# Should have buffered the message
assert len(agg.buffers) == 1
@pytest.mark.asyncio
async def test_max_buffer_flushes_immediately(self):
"""Reaching MAX_BUFFER_MESSAGES should flush immediately."""
aggregator = get_aggregator_module()
app = make_aggregator_app()
mock_pipeline = Mock()
mock_pipeline.pipeline_entity = Mock()
mock_pipeline.pipeline_entity.config = {
'trigger': {
'message-aggregation': {
'enabled': True,
'delay': 10.0, # Long delay
}
}
}
app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=mock_pipeline)
agg = aggregator.MessageAggregator(app)
chain = text_chain("hello")
event = friend_message_event(chain)
adapter = mock_adapter()
# Add messages up to MAX_BUFFER_MESSAGES
for i in range(aggregator.MAX_BUFFER_MESSAGES):
await agg.add_message(
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_event=event,
message_chain=chain,
adapter=adapter,
pipeline_uuid='test-pipeline',
)
# Buffer should be flushed (empty or no buffer)
session_id = agg._get_session_id('test-bot', provider_session.LauncherTypes.PERSON, 12345)
assert session_id not in agg.buffers or len(agg.buffers[session_id].messages) == 0
class TestMessageAggregatorMerge:
"""Tests for message merging."""
def test_merge_single_message(self):
"""Single message should return unchanged."""
aggregator = get_aggregator_module()
app = make_aggregator_app()
agg = aggregator.MessageAggregator(app)
chain = text_chain("hello")
event = friend_message_event(chain)
adapter = mock_adapter()
pending = aggregator.PendingMessage(
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_event=event,
message_chain=chain,
adapter=adapter,
pipeline_uuid=None,
)
merged = agg._merge_messages([pending])
assert merged.message_chain == chain
def test_merge_multiple_messages(self):
"""Multiple messages should be merged with newline separator."""
aggregator = get_aggregator_module()
app = make_aggregator_app()
agg = aggregator.MessageAggregator(app)
chain1 = text_chain("hello")
chain2 = text_chain("world")
event = friend_message_event(chain1)
adapter = mock_adapter()
pending1 = aggregator.PendingMessage(
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_event=event,
message_chain=chain1,
adapter=adapter,
pipeline_uuid=None,
)
pending2 = aggregator.PendingMessage(
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_event=event,
message_chain=chain2,
adapter=adapter,
pipeline_uuid=None,
)
merged = agg._merge_messages([pending1, pending2])
# Should contain both messages with separator
merged_str = str(merged.message_chain)
assert "hello" in merged_str
assert "world" in merged_str
def test_merge_messages_preserves_routed_by_rule_if_any_input_matches(self):
"""Merged PendingMessage should keep routed_by_rule when any input was rule-routed."""
aggregator = get_aggregator_module()
app = make_aggregator_app()
agg = aggregator.MessageAggregator(app)
chain1 = text_chain("first")
chain2 = text_chain("second")
event = friend_message_event(chain1)
adapter = mock_adapter()
pending1 = aggregator.PendingMessage(
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_event=event,
message_chain=chain1,
adapter=adapter,
pipeline_uuid='test-pipeline-uuid',
routed_by_rule=False,
)
pending2 = aggregator.PendingMessage(
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_event=event,
message_chain=chain2,
adapter=adapter,
pipeline_uuid='test-pipeline-uuid',
routed_by_rule=True,
)
merged = agg._merge_messages([pending1, pending2])
assert merged.routed_by_rule is True
assert str(merged.message_chain) == 'first\nsecond'
class TestMessageAggregatorFlush:
"""Tests for buffer flush behavior."""
@pytest.mark.asyncio
async def test_flush_empty_buffer(self):
"""Flushing empty buffer should do nothing."""
aggregator = get_aggregator_module()
app = make_aggregator_app()
agg = aggregator.MessageAggregator(app)
await agg._flush_buffer('nonexistent-session')
# Should not call query_pool
assert not app.query_pool.add_query.called
@pytest.mark.asyncio
async def test_flush_single_message(self):
"""Flushing single message should add directly to query_pool."""
aggregator = get_aggregator_module()
app = make_aggregator_app()
agg = aggregator.MessageAggregator(app)
chain = text_chain("hello")
event = friend_message_event(chain)
adapter = mock_adapter()
pending = aggregator.PendingMessage(
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_event=event,
message_chain=chain,
adapter=adapter,
pipeline_uuid=None,
)
buffer = aggregator.SessionBuffer(
session_id='test-session',
messages=[pending],
)
agg.buffers['test-session'] = buffer
await agg._flush_buffer('test-session')
assert app.query_pool.add_query.called
assert 'test-session' not in agg.buffers
class TestMessageAggregatorFlushAll:
"""Tests for flush_all behavior."""
@pytest.mark.asyncio
async def test_flush_all_empty(self):
"""flush_all with no buffers should do nothing."""
aggregator = get_aggregator_module()
app = make_aggregator_app()
agg = aggregator.MessageAggregator(app)
await agg.flush_all()
# Should not call query_pool
assert not app.query_pool.add_query.called
@pytest.mark.asyncio
async def test_flush_all_with_buffers(self):
"""flush_all should flush all pending buffers."""
aggregator = get_aggregator_module()
app = make_aggregator_app()
agg = aggregator.MessageAggregator(app)
chain = text_chain("hello")
event = friend_message_event(chain)
adapter = mock_adapter()
# Create two buffers
pending1 = aggregator.PendingMessage(
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_event=event,
message_chain=chain,
adapter=adapter,
pipeline_uuid=None,
)
pending2 = aggregator.PendingMessage(
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=67890,
sender_id=67890,
message_event=event,
message_chain=chain,
adapter=adapter,
pipeline_uuid=None,
)
buffer1 = aggregator.SessionBuffer(session_id='session-1', messages=[pending1])
buffer2 = aggregator.SessionBuffer(session_id='session-2', messages=[pending2])
agg.buffers['session-1'] = buffer1
agg.buffers['session-2'] = buffer2
await agg.flush_all()
# Both buffers should be flushed
assert len(agg.buffers) == 0
assert app.query_pool.add_query.call_count == 2
@@ -0,0 +1,436 @@
"""
Unit tests for ChatMessageHandler - REAL imports.
Tests the actual ChatMessageHandler class from production code.
Uses tests.utils.import_isolation to break circular import chain safely.
"""
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock
from tests.factories import FakeApp
# ============== FIXTURE USING IMPORT ISOLATION UTILITY ==============
@pytest.fixture(scope='module')
def mock_circular_import_chain():
"""
Break circular import chain using isolated_sys_modules.
Chain: handler → core.app → pipeline.controller → http_controller → groups/plugins → taskmgr
Uses tests.utils.import_isolation for safe, reversible sys.modules manipulation.
"""
from tests.utils.import_isolation import (
isolated_sys_modules,
make_pipeline_handler_import_mocks,
get_handler_modules_to_clear,
)
from langbot_plugin.api.entities.builtin.provider.message import Message
mocks = make_pipeline_handler_import_mocks()
# Create a default runner that yields a simple response
class DefaultRunner:
name = 'local-agent'
def __init__(self, app, config):
self.app = app
self.config = config
async def run(self, query):
yield Message(role='assistant', content='fake response')
mocks['langbot.pkg.provider.runner'].preregistered_runners = [DefaultRunner]
clear = get_handler_modules_to_clear('chat')
with isolated_sys_modules(mocks=mocks, clear=clear):
yield
@pytest.fixture
def fake_app():
"""Create FakeApp instance."""
return FakeApp()
@pytest.fixture
def mock_event_ctx():
"""Create mock event context."""
ctx = Mock()
ctx.is_prevented_default = Mock(return_value=False)
ctx.event = Mock()
ctx.event.user_message_alter = None
ctx.event.reply_message_chain = None
return ctx
@pytest.fixture
def set_runner():
"""Factory fixture to set a custom runner for tests."""
def _set_runner(runner_class):
import sys
sys.modules['langbot.pkg.provider.runner'].preregistered_runners = [runner_class]
return _set_runner
# ============== CACHED LAZY IMPORTS ==============
_chat_handler_module = None
_entities_module = None
def get_chat_handler():
"""Import ChatMessageHandler after circular import chain is mocked."""
global _chat_handler_module
if _chat_handler_module is None:
from importlib import import_module
_chat_handler_module = import_module('langbot.pkg.pipeline.process.handlers.chat')
return _chat_handler_module
def get_entities():
"""Import pipeline entities - uses real module."""
global _entities_module
if _entities_module is None:
from importlib import import_module
_entities_module = import_module('langbot.pkg.pipeline.entities')
return _entities_module
# ============== REAL ChatMessageHandler Tests ==============
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestChatMessageHandlerReal:
"""Tests for real ChatMessageHandler class."""
@pytest.mark.asyncio
async def test_real_import_works(self):
"""Verify we can import the real handler class."""
chat = get_chat_handler()
assert hasattr(chat, 'ChatMessageHandler')
handler_cls = chat.ChatMessageHandler
assert handler_cls.__name__ == 'ChatMessageHandler'
@pytest.mark.asyncio
async def test_handler_creation(self, fake_app):
"""ChatMessageHandler can be instantiated."""
chat = get_chat_handler()
handler = chat.ChatMessageHandler(fake_app)
assert handler.ap is fake_app
@pytest.mark.asyncio
async def test_prevent_default_without_reply_interrupts(self, fake_app, mock_event_ctx):
"""prevent_default without reply chain yields INTERRUPT."""
from tests.factories import text_query
chat = get_chat_handler()
entities = get_entities()
mock_event_ctx.is_prevented_default.return_value = True
mock_event_ctx.event.reply_message_chain = None
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
handler = chat.ChatMessageHandler(fake_app)
query = text_query('hello')
results = []
async for result in handler.handle(query):
results.append(result)
assert len(results) == 1
assert results[0].result_type == entities.ResultType.INTERRUPT
@pytest.mark.asyncio
async def test_prevent_default_with_reply_continues(self, fake_app, mock_event_ctx):
"""prevent_default with reply yields CONTINUE and updates resp_messages."""
from tests.factories import text_query, text_chain
chat = get_chat_handler()
entities = get_entities()
reply_chain = text_chain('plugin reply')
mock_event_ctx.is_prevented_default.return_value = True
mock_event_ctx.event.reply_message_chain = reply_chain
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
handler = chat.ChatMessageHandler(fake_app)
query = text_query('hello')
query.resp_messages = []
results = []
async for result in handler.handle(query):
results.append(result)
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
assert len(query.resp_messages) == 1
assert query.resp_messages[0] == reply_chain
@pytest.mark.asyncio
async def test_user_message_alter_string(self, fake_app, mock_event_ctx, set_runner):
"""user_message_alter as string updates query.user_message."""
from tests.factories import text_query
from langbot_plugin.api.entities.builtin.provider.message import Message
chat = get_chat_handler()
mock_event_ctx.is_prevented_default.return_value = False
mock_event_ctx.event.user_message_alter = 'altered text'
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
query = text_query('original')
query.adapter = Mock()
query.adapter.is_stream_output_supported = AsyncMock(return_value=False)
query.user_message = Message(role='user', content=[])
class QuickRunner:
name = 'local-agent'
def __init__(self, app, config):
self.app = app
self.config = config
async def run(self, query):
yield Message(role='assistant', content='ok')
set_runner(QuickRunner)
handler = chat.ChatMessageHandler(fake_app)
results = []
async for result in handler.handle(query):
results.append(result)
assert query.user_message.content is not None
@pytest.mark.asyncio
async def test_adapter_without_stream_method_defaults_non_stream(self, fake_app, mock_event_ctx, set_runner):
"""Adapter without is_stream_output_supported defaults to non-stream."""
from tests.factories import text_query
from langbot_plugin.api.entities.builtin.provider.message import Message, ContentElement
chat = get_chat_handler()
mock_event_ctx.is_prevented_default.return_value = False
mock_event_ctx.event.user_message_alter = None
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
query = text_query('test')
query.adapter = Mock(spec=[])
query.user_message = Message(role='user', content=[ContentElement.from_text('test')])
class SingleRunner:
name = 'local-agent'
def __init__(self, app, config):
self.app = app
self.config = config
async def run(self, query):
yield Message(role='assistant', content='response')
set_runner(SingleRunner)
handler = chat.ChatMessageHandler(fake_app)
results = []
async for result in handler.handle(query):
results.append(result)
assert len(results) >= 1
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestChatHandlerStreaming:
"""Tests for streaming behavior."""
@pytest.mark.asyncio
async def test_streaming_chunks_collected(self, fake_app, mock_event_ctx, set_runner):
"""Streaming produces multiple results."""
from tests.factories import text_query
from langbot_plugin.api.entities.builtin.provider.message import Message, ContentElement, MessageChunk
chat = get_chat_handler()
mock_event_ctx.is_prevented_default.return_value = False
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
query = text_query('stream test')
query.adapter = Mock()
query.adapter.is_stream_output_supported = AsyncMock(return_value=True)
query.adapter.create_message_card = AsyncMock()
query.user_message = Message(role='user', content=[ContentElement.from_text('test')])
class StreamRunner:
name = 'local-agent'
def __init__(self, app, config):
self.app = app
self.config = config
async def run(self, query):
yield MessageChunk(role='assistant', content='Hello', is_final=False)
yield MessageChunk(role='assistant', content=' World', is_final=True)
set_runner(StreamRunner)
handler = chat.ChatMessageHandler(fake_app)
results = []
async for result in handler.handle(query):
results.append(result)
assert len(results) >= 1
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestChatHandlerExceptions:
"""Tests for exception handling."""
@pytest.mark.asyncio
async def test_runner_exception_yields_interrupt(self, fake_app, mock_event_ctx, set_runner):
"""Runner exception yields INTERRUPT with error notices."""
from tests.factories import text_query
from langbot_plugin.api.entities.builtin.provider.message import Message
chat = get_chat_handler()
entities = get_entities()
mock_event_ctx.is_prevented_default.return_value = False
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
query = text_query('fail test')
query.adapter = Mock()
query.adapter.is_stream_output_supported = AsyncMock(return_value=False)
query.user_message = Message(role='user', content=[])
query.pipeline_config = {
'output': {'misc': {'exception-handling': 'show-hint', 'failure-hint': 'Request failed.'}},
'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}}},
}
class FailingRunner:
name = 'local-agent'
def __init__(self, app, config):
self.app = app
self.config = config
async def run(self, query):
raise ValueError('API error')
yield
set_runner(FailingRunner)
handler = chat.ChatMessageHandler(fake_app)
results = []
async for result in handler.handle(query):
results.append(result)
assert len(results) == 1
assert results[0].result_type == entities.ResultType.INTERRUPT
assert results[0].user_notice == 'Request failed.'
assert results[0].error_notice is not None
@pytest.mark.asyncio
async def test_exception_show_error_mode(self, fake_app, mock_event_ctx, set_runner):
"""show-error mode shows actual exception."""
from tests.factories import text_query
from langbot_plugin.api.entities.builtin.provider.message import Message
chat = get_chat_handler()
mock_event_ctx.is_prevented_default.return_value = False
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
query = text_query('error test')
query.adapter = Mock()
query.adapter.is_stream_output_supported = AsyncMock(return_value=False)
query.user_message = Message(role='user', content=[])
query.pipeline_config = {
'output': {'misc': {'exception-handling': 'show-error'}},
'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}}},
}
class ErrorRunner:
name = 'local-agent'
def __init__(self, app, config):
self.app = app
self.config = config
async def run(self, query):
raise ValueError('Custom error')
yield
set_runner(ErrorRunner)
handler = chat.ChatMessageHandler(fake_app)
results = []
async for result in handler.handle(query):
results.append(result)
assert results[0].user_notice == 'Custom error'
@pytest.mark.asyncio
async def test_exception_hide_mode(self, fake_app, mock_event_ctx, set_runner):
"""hide mode shows no user notice."""
from tests.factories import text_query
from langbot_plugin.api.entities.builtin.provider.message import Message
chat = get_chat_handler()
mock_event_ctx.is_prevented_default.return_value = False
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
query = text_query('hide test')
query.adapter = Mock()
query.adapter.is_stream_output_supported = AsyncMock(return_value=False)
query.user_message = Message(role='user', content=[])
query.pipeline_config = {
'output': {'misc': {'exception-handling': 'hide'}},
'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': {'prompt': 'default', 'model': {'primary': 'test'}}},
}
class HideErrorRunner:
name = 'local-agent'
def __init__(self, app, config):
self.app = app
self.config = config
async def run(self, query):
raise RuntimeError('hidden')
yield
set_runner(HideErrorRunner)
handler = chat.ChatMessageHandler(fake_app)
results = []
async for result in handler.handle(query):
results.append(result)
assert results[0].user_notice is None
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestChatHandlerHelper:
"""Tests for helper methods."""
def test_cut_str_short(self, fake_app):
"""cut_str returns short string unchanged."""
chat = get_chat_handler()
handler = chat.ChatMessageHandler(fake_app)
result = handler.cut_str('short text')
assert result == 'short text'
def test_cut_str_long(self, fake_app):
"""cut_str truncates long string."""
chat = get_chat_handler()
handler = chat.ChatMessageHandler(fake_app)
result = handler.cut_str('this is a very long string that exceeds twenty characters')
assert '...' in result
assert len(result) <= 23
def test_cut_str_multiline(self, fake_app):
"""cut_str truncates multiline string."""
chat = get_chat_handler()
handler = chat.ChatMessageHandler(fake_app)
result = handler.cut_str('first line\nsecond line')
assert '...' in result
@@ -91,7 +91,11 @@ async def test_preprocessor_keeps_conversation_when_last_update_is_not_expired(m
def test_expire_time_metadata_lives_under_ai_runner_not_safety():
metadata_dir = Path('src/langbot/templates/metadata/pipeline')
# Use path relative to test file location for portability
# test file: tests/unit_tests/pipeline/test_chat_session_limit.py
# project root: 4 levels up
project_root = Path(__file__).parent.parent.parent.parent
metadata_dir = project_root / 'src' / 'langbot' / 'templates' / 'metadata' / 'pipeline'
ai_meta = yaml.safe_load((metadata_dir / 'ai.yaml').read_text())
safety_meta = yaml.safe_load((metadata_dir / 'safety.yaml').read_text())
+514
View File
@@ -0,0 +1,514 @@
"""
Unit tests for ContentFilterStage (cntfilter) pipeline stage.
Tests cover:
- Pre-filter behavior (income message filtering)
- Post-filter behavior (output message filtering)
- Content ignore rules (prefix/regexp)
- Pass/Block/Masked result handling
- CONTINUE/INTERRUPT flow control
"""
from __future__ import annotations
import pytest
from unittest.mock import Mock
from importlib import import_module
from tests.factories import (
FakeApp,
text_query,
image_query,
)
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.platform.message as platform_message
def get_cntfilter_module():
"""Lazy import to avoid circular import issues."""
# Import pipelinemgr first to trigger stage registration
import_module('langbot.pkg.pipeline.pipelinemgr')
return import_module('langbot.pkg.pipeline.cntfilter.cntfilter')
def get_filter_module():
"""Lazy import for filter base."""
return import_module('langbot.pkg.pipeline.cntfilter.filter')
def get_entities_module():
"""Lazy import for pipeline entities."""
return import_module('langbot.pkg.pipeline.entities')
def get_filter_entities_module():
"""Lazy import for filter entities."""
return import_module('langbot.pkg.pipeline.cntfilter.entities')
def make_pipeline_config(**overrides):
"""Create a pipeline config with defaults for content filter tests."""
base_config = {
'safety': {
'content-filter': {
'check-sensitive-words': False,
'scope': 'both',
}
},
'trigger': {
'ignore-rules': {
'prefix': [],
'regexp': [],
}
},
}
# Deep merge for nested dicts
for key, value in overrides.items():
if key in base_config and isinstance(base_config[key], dict) and isinstance(value, dict):
for sub_key, sub_value in value.items():
if sub_key in base_config[key] and isinstance(base_config[key][sub_key], dict) and isinstance(sub_value, dict):
base_config[key][sub_key].update(sub_value)
else:
base_config[key][sub_key] = sub_value
else:
base_config[key] = value
return base_config
class TestContentFilterStageInit:
"""Tests for ContentFilterStage initialization."""
@pytest.mark.asyncio
async def test_initialize_basic_filters(self):
"""Initialize should load required filters."""
cntfilter = get_cntfilter_module()
app = FakeApp()
stage = cntfilter.ContentFilterStage(app)
pipeline_config = make_pipeline_config()
await stage.initialize(pipeline_config)
assert [filter_impl.name for filter_impl in stage.filter_chain] == ['content-ignore']
@pytest.mark.asyncio
async def test_initialize_with_sensitive_words(self):
"""Initialize with sensitive words should load ban-word-filter."""
cntfilter = get_cntfilter_module()
app = FakeApp()
# Mock sensitive_meta for ban-word-filter
app.sensitive_meta = Mock()
app.sensitive_meta.data = {
'words': [],
'mask': '*',
'mask_word': '',
}
stage = cntfilter.ContentFilterStage(app)
pipeline_config = make_pipeline_config(
safety={
'content-filter': {
'check-sensitive-words': True,
}
}
)
await stage.initialize(pipeline_config)
assert {filter_impl.name for filter_impl in stage.filter_chain} == {
'ban-word-filter',
'content-ignore',
}
class TestPreContentFilter:
"""Tests for PreContentFilterStage (income message filtering)."""
@pytest.mark.asyncio
async def test_normal_text_continues(self):
"""Normal text message should continue pipeline."""
cntfilter = get_cntfilter_module()
entities = get_entities_module()
app = FakeApp()
stage = cntfilter.ContentFilterStage(app)
pipeline_config = make_pipeline_config()
await stage.initialize(pipeline_config)
query = text_query("hello world")
query.pipeline_config = pipeline_config
result = await stage.process(query, 'PreContentFilterStage')
assert result.result_type == entities.ResultType.CONTINUE
assert result.new_query is not None
@pytest.mark.asyncio
async def test_empty_text_continues(self):
"""Empty text message should continue pipeline."""
cntfilter = get_cntfilter_module()
entities = get_entities_module()
app = FakeApp()
stage = cntfilter.ContentFilterStage(app)
pipeline_config = make_pipeline_config()
await stage.initialize(pipeline_config)
# Empty message chain
query = text_query("")
query.message_chain = platform_message.MessageChain([])
query.pipeline_config = pipeline_config
result = await stage.process(query, 'PreContentFilterStage')
# Empty messages should continue
assert result.result_type == entities.ResultType.CONTINUE
@pytest.mark.asyncio
async def test_whitespace_only_continues(self):
"""Whitespace-only message should continue pipeline."""
cntfilter = get_cntfilter_module()
entities = get_entities_module()
app = FakeApp()
stage = cntfilter.ContentFilterStage(app)
pipeline_config = make_pipeline_config()
await stage.initialize(pipeline_config)
query = text_query(" ") # Only whitespace
query.pipeline_config = pipeline_config
result = await stage.process(query, 'PreContentFilterStage')
# Whitespace-only should continue (stripped becomes empty)
assert result.result_type == entities.ResultType.CONTINUE
@pytest.mark.asyncio
async def test_non_text_component_continues(self):
"""Message with non-text components should continue (skip filter)."""
cntfilter = get_cntfilter_module()
entities = get_entities_module()
app = FakeApp()
stage = cntfilter.ContentFilterStage(app)
pipeline_config = make_pipeline_config()
await stage.initialize(pipeline_config)
# Image message (non-text)
query = image_query()
query.pipeline_config = pipeline_config
result = await stage.process(query, 'PreContentFilterStage')
# Non-text messages should continue (skip filter)
assert result.result_type == entities.ResultType.CONTINUE
@pytest.mark.asyncio
async def test_output_scope_skip_pre_filter(self):
"""scope=output-msg should skip pre-filter."""
cntfilter = get_cntfilter_module()
entities = get_entities_module()
app = FakeApp()
stage = cntfilter.ContentFilterStage(app)
pipeline_config = make_pipeline_config(
safety={
'content-filter': {
'scope': 'output-msg', # Only check output
}
}
)
await stage.initialize(pipeline_config)
query = text_query("hello world")
query.pipeline_config = pipeline_config
result = await stage.process(query, 'PreContentFilterStage')
# Should continue without filtering
assert result.result_type == entities.ResultType.CONTINUE
class TestContentIgnoreFilter:
"""Tests for content-ignore filter rules."""
@pytest.mark.asyncio
async def test_prefix_rule_blocks(self):
"""Message matching prefix ignore rule should be blocked."""
cntfilter = get_cntfilter_module()
entities = get_entities_module()
app = FakeApp()
stage = cntfilter.ContentFilterStage(app)
pipeline_config = make_pipeline_config(
trigger={
'ignore-rules': {
'prefix': ['/help', '/ping'],
'regexp': [],
}
}
)
await stage.initialize(pipeline_config)
query = text_query("/help me")
query.pipeline_config = pipeline_config
result = await stage.process(query, 'PreContentFilterStage')
# Should be interrupted due to prefix rule
assert result.result_type == entities.ResultType.INTERRUPT
@pytest.mark.asyncio
async def test_regexp_rule_blocks(self):
"""Message matching regexp ignore rule should be blocked."""
cntfilter = get_cntfilter_module()
entities = get_entities_module()
app = FakeApp()
stage = cntfilter.ContentFilterStage(app)
pipeline_config = make_pipeline_config(
trigger={
'ignore-rules': {
'prefix': [],
'regexp': ['^http://.*', r'\d{10}'],
}
}
)
await stage.initialize(pipeline_config)
query = text_query("http://example.com")
query.pipeline_config = pipeline_config
result = await stage.process(query, 'PreContentFilterStage')
# Should be interrupted due to regexp rule
assert result.result_type == entities.ResultType.INTERRUPT
@pytest.mark.asyncio
async def test_no_rule_match_continues(self):
"""Message not matching any rule should continue."""
cntfilter = get_cntfilter_module()
entities = get_entities_module()
app = FakeApp()
stage = cntfilter.ContentFilterStage(app)
pipeline_config = make_pipeline_config(
trigger={
'ignore-rules': {
'prefix': ['/help', '/ping'],
'regexp': ['^http://.*'],
}
}
)
await stage.initialize(pipeline_config)
query = text_query("normal message")
query.pipeline_config = pipeline_config
result = await stage.process(query, 'PreContentFilterStage')
# Should continue (no rule match)
assert result.result_type == entities.ResultType.CONTINUE
@pytest.mark.asyncio
async def test_empty_rules_continues(self):
"""Empty ignore rules should not block any message."""
cntfilter = get_cntfilter_module()
entities = get_entities_module()
app = FakeApp()
stage = cntfilter.ContentFilterStage(app)
pipeline_config = make_pipeline_config()
await stage.initialize(pipeline_config)
query = text_query("/help me")
query.pipeline_config = pipeline_config
result = await stage.process(query, 'PreContentFilterStage')
# Should continue (empty rules)
assert result.result_type == entities.ResultType.CONTINUE
class TestPostContentFilter:
"""Tests for PostContentFilterStage (output message filtering)."""
@pytest.mark.asyncio
async def test_normal_response_continues(self):
"""Normal response message should continue pipeline."""
cntfilter = get_cntfilter_module()
entities = get_entities_module()
app = FakeApp()
stage = cntfilter.ContentFilterStage(app)
pipeline_config = make_pipeline_config()
await stage.initialize(pipeline_config)
query = text_query("hello")
query.pipeline_config = pipeline_config
# Add a response message
query.resp_messages = [
provider_message.Message(role='assistant', content='Hello back!')
]
result = await stage.process(query, 'PostContentFilterStage')
assert result.result_type == entities.ResultType.CONTINUE
@pytest.mark.asyncio
async def test_income_scope_skip_post_filter(self):
"""scope=income-msg should skip post-filter."""
cntfilter = get_cntfilter_module()
entities = get_entities_module()
app = FakeApp()
stage = cntfilter.ContentFilterStage(app)
pipeline_config = make_pipeline_config(
safety={
'content-filter': {
'scope': 'income-msg', # Only check income
}
}
)
await stage.initialize(pipeline_config)
query = text_query("hello")
query.pipeline_config = pipeline_config
query.resp_messages = [
provider_message.Message(role='assistant', content='Response')
]
result = await stage.process(query, 'PostContentFilterStage')
# Should continue without filtering
assert result.result_type == entities.ResultType.CONTINUE
@pytest.mark.asyncio
async def test_non_string_content_continues(self):
"""Non-string content should continue (skip filter)."""
cntfilter = get_cntfilter_module()
entities = get_entities_module()
app = FakeApp()
stage = cntfilter.ContentFilterStage(app)
pipeline_config = make_pipeline_config()
await stage.initialize(pipeline_config)
query = text_query("hello")
query.pipeline_config = pipeline_config
# Non-string content - use model_construct to bypass validation
# The actual content type could be a list of ContentElement objects
non_string_msg = provider_message.Message.model_construct(
role='assistant',
content=[Mock()], # Mock content element
)
query.resp_messages = [non_string_msg]
result = await stage.process(query, 'PostContentFilterStage')
# Should continue (skip filter for non-string)
assert result.result_type == entities.ResultType.CONTINUE
@pytest.mark.asyncio
async def test_empty_response_continues(self):
"""Empty response should continue pipeline."""
cntfilter = get_cntfilter_module()
entities = get_entities_module()
app = FakeApp()
stage = cntfilter.ContentFilterStage(app)
pipeline_config = make_pipeline_config()
await stage.initialize(pipeline_config)
query = text_query("hello")
query.pipeline_config = pipeline_config
query.resp_messages = [
provider_message.Message(role='assistant', content='')
]
result = await stage.process(query, 'PostContentFilterStage')
assert result.result_type == entities.ResultType.CONTINUE
class TestContentFilterStageInvalidName:
"""Tests for invalid stage_inst_name handling."""
@pytest.mark.asyncio
async def test_unknown_stage_name_raises(self):
"""Unknown stage_inst_name should raise ValueError."""
cntfilter = get_cntfilter_module()
app = FakeApp()
stage = cntfilter.ContentFilterStage(app)
pipeline_config = make_pipeline_config()
await stage.initialize(pipeline_config)
query = text_query("hello")
query.pipeline_config = pipeline_config
with pytest.raises(ValueError, match='未知的 stage_inst_name'):
await stage.process(query, 'UnknownStage')
class TestContentIgnoreFilterDirect:
"""Direct tests for ContentIgnore filter."""
@pytest.mark.asyncio
async def test_content_ignore_pass(self):
"""ContentIgnore should PASS for non-matching messages."""
cntfilter = get_cntfilter_module()
app = FakeApp()
stage = cntfilter.ContentFilterStage(app)
pipeline_config = make_pipeline_config(
trigger={
'ignore-rules': {
'prefix': ['/test'],
'regexp': [],
}
}
)
await stage.initialize(pipeline_config)
query = text_query("normal message without prefix")
query.pipeline_config = pipeline_config
result = await stage.process(query, 'PreContentFilterStage')
assert result.result_type == cntfilter.entities.ResultType.CONTINUE
@@ -0,0 +1,396 @@
"""
Unit tests for CommandHandler - REAL imports.
Tests the actual CommandHandler class from production code.
Uses tests.utils.import_isolation to break circular import chain safely.
"""
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock
from tests.factories import FakeApp, command_query
# ============== FIXTURE USING IMPORT ISOLATION UTILITY ==============
@pytest.fixture(scope='module')
def mock_circular_import_chain():
"""
Break circular import chain using isolated_sys_modules.
Chain: handler → core.app → pipeline.controller → http_controller → groups/plugins → taskmgr
Uses tests.utils.import_isolation for safe, reversible sys.modules manipulation.
"""
from tests.utils.import_isolation import (
isolated_sys_modules,
make_pipeline_handler_import_mocks,
get_handler_modules_to_clear,
)
mocks = make_pipeline_handler_import_mocks()
clear = get_handler_modules_to_clear('command')
with isolated_sys_modules(mocks=mocks, clear=clear):
yield
@pytest.fixture
def fake_app():
"""Create FakeApp instance."""
return FakeApp()
@pytest.fixture
def mock_event_ctx():
"""Create mock event context."""
ctx = Mock()
ctx.is_prevented_default = Mock(return_value=False)
ctx.event = Mock()
ctx.event.reply_message_chain = None
return ctx
@pytest.fixture
def mock_execute_factory():
"""Factory fixture to create mock cmd_mgr.execute generators."""
def _create_execute(
text: str | None = 'ok',
error: str | None = None,
image_url: str | None = None,
image_base64: str | None = None,
file_url: str | None = None,
):
async def mock_execute(command_text, full_command_text, query, session):
ret = Mock()
ret.text = text
ret.error = error
ret.image_url = image_url
ret.image_base64 = image_base64
ret.file_url = file_url
yield ret
return mock_execute
return _create_execute
# ============== CACHED LAZY IMPORTS ==============
_command_handler_module = None
_entities_module = None
def get_command_handler():
"""Import CommandHandler after circular import chain is mocked."""
global _command_handler_module
if _command_handler_module is None:
from importlib import import_module
_command_handler_module = import_module('langbot.pkg.pipeline.process.handlers.command')
return _command_handler_module
def get_entities():
"""Import pipeline entities - uses real module."""
global _entities_module
if _entities_module is None:
from importlib import import_module
_entities_module = import_module('langbot.pkg.pipeline.entities')
return _entities_module
# ============== REAL CommandHandler Tests ==============
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestCommandHandlerReal:
"""Tests for real CommandHandler class."""
@pytest.mark.asyncio
async def test_real_import_works(self):
"""Verify we can import the real handler class."""
command = get_command_handler()
assert hasattr(command, 'CommandHandler')
handler_cls = command.CommandHandler
assert handler_cls.__name__ == 'CommandHandler'
@pytest.mark.asyncio
async def test_handler_creation(self, fake_app):
"""CommandHandler can be instantiated."""
command = get_command_handler()
handler = command.CommandHandler(fake_app)
assert handler.ap is fake_app
@pytest.mark.asyncio
async def test_command_parsing_extracts_command_name(self, fake_app, mock_event_ctx):
"""Command text is extracted after prefix."""
command = get_command_handler()
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
executed_commands = []
async def track_execute(command_text, full_command_text, query, session):
executed_commands.append(command_text)
ret = Mock()
ret.text = 'ok'
ret.error = None
ret.image_url = None
ret.image_base64 = None
ret.file_url = None
yield ret
fake_app.cmd_mgr.execute = track_execute
handler = command.CommandHandler(fake_app)
query = command_query('help arg1 arg2')
results = []
async for result in handler.handle(query):
results.append(result)
assert executed_commands[0] == 'help arg1 arg2'
@pytest.mark.asyncio
async def test_admin_privilege_check(self, fake_app, mock_event_ctx, mock_execute_factory):
"""Admin users get privilege level 2."""
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
command = get_command_handler()
fake_app.instance_config.data = {'admins': ['person_12345']}
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
fake_app.cmd_mgr.execute = mock_execute_factory()
handler = command.CommandHandler(fake_app)
query = command_query('status')
query.launcher_type = LauncherTypes.PERSON
query.launcher_id = 12345
results = []
async for result in handler.handle(query):
results.append(result)
call_args = fake_app.plugin_connector.emit_event.call_args
event = call_args[0][0]
assert event.is_admin is True
@pytest.mark.asyncio
async def test_non_admin_privilege_check(self, fake_app, mock_event_ctx, mock_execute_factory):
"""Non-admin users get privilege level 1."""
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
command = get_command_handler()
fake_app.instance_config.data = {'admins': ['person_12345']}
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
fake_app.cmd_mgr.execute = mock_execute_factory()
handler = command.CommandHandler(fake_app)
query = command_query('status')
query.launcher_type = LauncherTypes.PERSON
query.launcher_id = 67890
results = []
async for result in handler.handle(query):
results.append(result)
call_args = fake_app.plugin_connector.emit_event.call_args
event = call_args[0][0]
assert event.is_admin is False
@pytest.mark.asyncio
async def test_prevent_default_with_reply_continues(self, fake_app, mock_event_ctx):
"""prevent_default with reply yields CONTINUE."""
from tests.factories.message import text_chain
command = get_command_handler()
entities = get_entities()
reply_chain = text_chain('plugin reply')
mock_event_ctx.is_prevented_default.return_value = True
mock_event_ctx.event.reply_message_chain = reply_chain
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
handler = command.CommandHandler(fake_app)
query = command_query('test')
query.resp_messages = []
results = []
async for result in handler.handle(query):
results.append(result)
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
assert len(query.resp_messages) == 1
assert query.resp_messages[0] == reply_chain
@pytest.mark.asyncio
async def test_prevent_default_without_reply_interrupts(self, fake_app, mock_event_ctx):
"""prevent_default without reply yields INTERRUPT."""
command = get_command_handler()
entities = get_entities()
mock_event_ctx.is_prevented_default.return_value = True
mock_event_ctx.event.reply_message_chain = None
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
handler = command.CommandHandler(fake_app)
query = command_query('test')
results = []
async for result in handler.handle(query):
results.append(result)
assert len(results) == 1
assert results[0].result_type == entities.ResultType.INTERRUPT
@pytest.mark.asyncio
async def test_event_type_person_command(self, fake_app, mock_event_ctx, mock_execute_factory):
"""Person launcher creates PersonCommandSent event."""
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
from langbot_plugin.api.entities import events
command = get_command_handler()
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
fake_app.cmd_mgr.execute = mock_execute_factory()
handler = command.CommandHandler(fake_app)
query = command_query('help')
query.launcher_type = LauncherTypes.PERSON
results = []
async for result in handler.handle(query):
results.append(result)
call_args = fake_app.plugin_connector.emit_event.call_args
event = call_args[0][0]
assert isinstance(event, events.PersonCommandSent)
@pytest.mark.asyncio
async def test_event_type_group_command(self, fake_app, mock_event_ctx, mock_execute_factory):
"""Group launcher creates GroupCommandSent event."""
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
from langbot_plugin.api.entities import events
command = get_command_handler()
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
fake_app.cmd_mgr.execute = mock_execute_factory()
handler = command.CommandHandler(fake_app)
query = command_query('help')
query.launcher_type = LauncherTypes.GROUP
results = []
async for result in handler.handle(query):
results.append(result)
call_args = fake_app.plugin_connector.emit_event.call_args
event = call_args[0][0]
assert isinstance(event, events.GroupCommandSent)
@pytest.mark.asyncio
async def test_command_result_text(self, fake_app, mock_event_ctx, mock_execute_factory):
"""Text result is added to resp_messages."""
command = get_command_handler()
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
fake_app.cmd_mgr.execute = mock_execute_factory(text='Command output')
handler = command.CommandHandler(fake_app)
query = command_query('echo')
query.resp_messages = []
results = []
async for result in handler.handle(query):
results.append(result)
assert len(query.resp_messages) == 1
msg = query.resp_messages[0]
assert msg.role == 'command'
assert len(msg.content) == 1
assert msg.content[0].type == 'text'
assert msg.content[0].text == 'Command output'
@pytest.mark.asyncio
async def test_command_result_error(self, fake_app, mock_event_ctx, mock_execute_factory):
"""Error result creates error message."""
command = get_command_handler()
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
fake_app.cmd_mgr.execute = mock_execute_factory(text=None, error='Command failed')
handler = command.CommandHandler(fake_app)
query = command_query('fail')
query.resp_messages = []
results = []
async for result in handler.handle(query):
results.append(result)
assert len(query.resp_messages) == 1
msg = query.resp_messages[0]
assert msg.role == 'command'
assert msg.content == 'Command failed'
@pytest.mark.asyncio
async def test_command_result_image_url(self, fake_app, mock_event_ctx, mock_execute_factory):
"""Image URL result is added to content."""
command = get_command_handler()
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
fake_app.cmd_mgr.execute = mock_execute_factory(
text='Here is the image:',
image_url='https://example.com/image.png'
)
handler = command.CommandHandler(fake_app)
query = command_query('image')
query.resp_messages = []
results = []
async for result in handler.handle(query):
results.append(result)
msg = query.resp_messages[0]
assert len(msg.content) == 2
assert msg.content[0].type == 'text'
assert msg.content[1].type == 'image_url'
@pytest.mark.asyncio
async def test_command_result_empty_interrupts(self, fake_app, mock_event_ctx, mock_execute_factory):
"""Empty result yields INTERRUPT."""
command = get_command_handler()
entities = get_entities()
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
fake_app.cmd_mgr.execute = mock_execute_factory(text=None)
handler = command.CommandHandler(fake_app)
query = command_query('empty')
results = []
async for result in handler.handle(query):
results.append(result)
assert results[0].result_type == entities.ResultType.INTERRUPT
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestCommandHandlerHelper:
"""Tests for helper methods."""
def test_cut_str_short(self, fake_app):
"""cut_str returns short string unchanged."""
command = get_command_handler()
handler = command.CommandHandler(fake_app)
result = handler.cut_str('short text')
assert result == 'short text'
def test_cut_str_long(self, fake_app):
"""cut_str truncates long string."""
command = get_command_handler()
handler = command.CommandHandler(fake_app)
result = handler.cut_str('this is a very long string that exceeds twenty characters')
assert '...' in result
assert len(result) <= 23
def test_cut_str_multiline(self, fake_app):
"""cut_str truncates multiline string."""
command = get_command_handler()
handler = command.CommandHandler(fake_app)
result = handler.cut_str('first line\nsecond line')
assert '...' in result
+367
View File
@@ -0,0 +1,367 @@
"""
Unit tests for LongTextProcessStage (longtext) pipeline stage.
Tests cover:
- Strategy selection (none/image/forward)
- Threshold boundary handling
- Plain/non-Plain component handling
- Strategy initialization and process
"""
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock
from importlib import import_module
from tests.factories import (
FakeApp,
text_query,
)
import langbot_plugin.api.entities.builtin.platform.message as platform_message
def get_longtext_module():
"""Lazy import to avoid circular import issues."""
# Import pipelinemgr first to trigger stage registration
import_module('langbot.pkg.pipeline.pipelinemgr')
return import_module('langbot.pkg.pipeline.longtext.longtext')
def get_strategy_module():
"""Lazy import for strategy base."""
return import_module('langbot.pkg.pipeline.longtext.strategy')
def get_entities_module():
"""Lazy import for pipeline entities."""
return import_module('langbot.pkg.pipeline.entities')
def make_longtext_config(strategy: str = 'none', threshold: int = 1000):
"""Create a pipeline config for long text processing."""
return {
'output': {
'long-text-processing': {
'strategy': strategy,
'threshold': threshold,
'font-path': '/nonexistent/font.ttf', # For image strategy
}
}
}
class TestLongTextProcessStageInit:
"""Tests for LongTextProcessStage initialization."""
@pytest.mark.asyncio
async def test_initialize_none_strategy(self):
"""Initialize with strategy='none' should set strategy_impl to None."""
longtext = get_longtext_module()
app = FakeApp()
stage = longtext.LongTextProcessStage(app)
pipeline_config = make_longtext_config(strategy='none')
await stage.initialize(pipeline_config)
assert stage.strategy_impl is None
@pytest.mark.asyncio
async def test_initialize_forward_strategy(self):
"""Initialize with strategy='forward' should use ForwardComponentStrategy."""
longtext = get_longtext_module()
strategy = get_strategy_module()
app = FakeApp()
stage = longtext.LongTextProcessStage(app)
pipeline_config = make_longtext_config(strategy='forward')
await stage.initialize(pipeline_config)
assert stage.strategy_impl is not None
assert isinstance(stage.strategy_impl, strategy.LongTextStrategy)
@pytest.mark.asyncio
async def test_initialize_unknown_strategy_raises(self):
"""Initialize with unknown strategy should raise ValueError."""
longtext = get_longtext_module()
strategy = get_strategy_module()
# Save original preregistered_strategies
original_strategies = strategy.preregistered_strategies.copy()
try:
# Clear registered strategies to simulate unknown
strategy.preregistered_strategies = []
app = FakeApp()
stage = longtext.LongTextProcessStage(app)
pipeline_config = make_longtext_config(strategy='unknown')
with pytest.raises(ValueError, match='Long message processing strategy not found'):
await stage.initialize(pipeline_config)
finally:
# Restore original strategies
strategy.preregistered_strategies = original_strategies
class TestLongTextProcessStageProcess:
"""Tests for LongTextProcessStage process behavior."""
@pytest.mark.asyncio
async def test_none_strategy_continues(self):
"""strategy='none' should always continue."""
longtext = get_longtext_module()
entities = get_entities_module()
app = FakeApp()
stage = longtext.LongTextProcessStage(app)
pipeline_config = make_longtext_config(strategy='none')
await stage.initialize(pipeline_config)
query = text_query("hello")
query.pipeline_config = pipeline_config
query.resp_message_chain = [
platform_message.MessageChain([platform_message.Plain(text="very long response")])
]
result = await stage.process(query, 'LongTextProcessStage')
assert result.result_type == entities.ResultType.CONTINUE
assert result.new_query is not None
@pytest.mark.asyncio
async def test_short_text_continues_without_transform(self):
"""Text shorter than threshold should not be transformed."""
longtext = get_longtext_module()
entities = get_entities_module()
app = FakeApp()
stage = longtext.LongTextProcessStage(app)
# High threshold so text won't trigger transform
pipeline_config = make_longtext_config(strategy='forward', threshold=10000)
await stage.initialize(pipeline_config)
query = text_query("hello")
query.pipeline_config = pipeline_config
query.resp_message_chain = [
platform_message.MessageChain([platform_message.Plain(text="short response")])
]
result = await stage.process(query, 'LongTextProcessStage')
assert result.result_type == entities.ResultType.CONTINUE
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):
"""resp_message_chain with non-Plain components should skip processing."""
longtext = get_longtext_module()
entities = get_entities_module()
app = FakeApp()
stage = longtext.LongTextProcessStage(app)
pipeline_config = make_longtext_config(strategy='forward', threshold=10) # Low threshold
await stage.initialize(pipeline_config)
query = text_query("hello")
query.pipeline_config = pipeline_config
# Non-Plain component (Image)
query.resp_message_chain = [
platform_message.MessageChain([
platform_message.Plain(text="short"),
platform_message.Image(url="https://example.com/img.png")
])
]
result = await stage.process(query, 'LongTextProcessStage')
assert result.result_type == entities.ResultType.CONTINUE
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'
@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 = []
result = await stage.process(query, 'LongTextProcessStage')
assert result.result_type == entities.ResultType.CONTINUE
assert result.new_query is query
@pytest.mark.asyncio
async def test_empty_response_message_chain_does_not_call_strategy(self):
"""Empty response chains should be a no-op for long text processing."""
longtext = get_longtext_module()
entities = get_entities_module()
app = FakeApp()
stage = longtext.LongTextProcessStage(app)
stage.strategy_impl = AsyncMock()
query = text_query("hello")
query.pipeline_config = make_longtext_config(strategy='forward', threshold=1)
query.resp_message_chain = []
result = await stage.process(query, 'LongTextProcessStage')
assert result.result_type == entities.ResultType.CONTINUE
assert result.new_query is query
stage.strategy_impl.process.assert_not_called()
class TestForwardStrategy:
"""Tests for ForwardComponentStrategy."""
@pytest.mark.asyncio
async def test_forward_strategy_processes(self):
"""ForwardComponentStrategy should create Forward component."""
longtext = get_longtext_module()
get_strategy_module()
entities = get_entities_module()
app = FakeApp()
stage = longtext.LongTextProcessStage(app)
# Low threshold to trigger
pipeline_config = make_longtext_config(strategy='forward', threshold=10)
await stage.initialize(pipeline_config)
query = text_query("hello")
query.pipeline_config = pipeline_config
# Create a mock adapter with bot_account_id
mock_adapter = Mock()
mock_adapter.bot_account_id = '12345'
query.adapter = mock_adapter
# Long text exceeding threshold
long_text = "This is a very long response that exceeds the threshold"
query.resp_message_chain = [
platform_message.MessageChain([platform_message.Plain(text=long_text)])
]
result = await stage.process(query, 'LongTextProcessStage')
assert result.result_type == entities.ResultType.CONTINUE
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):
"""Test ForwardComponentStrategy process method directly."""
strategy = get_strategy_module()
app = FakeApp()
# Get ForwardComponentStrategy from preregistered
for strat_cls in strategy.preregistered_strategies:
if strat_cls.name == 'forward':
strat = strat_cls(app)
break
else:
pytest.skip('ForwardComponentStrategy not registered')
await strat.initialize()
query = text_query("hello")
query.pipeline_config = make_longtext_config()
mock_adapter = Mock()
mock_adapter.bot_account_id = '12345'
query.adapter = mock_adapter
components = await strat.process("test message", query)
assert len(components) == 1
assert isinstance(components[0], platform_message.Forward)
class TestLongTextThreshold:
"""Tests for threshold boundary handling."""
@pytest.mark.asyncio
async def test_below_threshold_not_processed(self):
"""Text below threshold should not be transformed."""
longtext = get_longtext_module()
entities = get_entities_module()
app = FakeApp()
stage = longtext.LongTextProcessStage(app)
threshold = 100
pipeline_config = make_longtext_config(strategy='forward', threshold=threshold)
await stage.initialize(pipeline_config)
query = text_query("hello")
query.pipeline_config = pipeline_config
# Text below threshold
short_text = "x" * (threshold - 1)
query.resp_message_chain = [
platform_message.MessageChain([platform_message.Plain(text=short_text)])
]
result = await stage.process(query, 'LongTextProcessStage')
assert result.result_type == entities.ResultType.CONTINUE
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:
"""Tests for image strategy handling (requires PIL/font)."""
@pytest.mark.asyncio
async def test_image_strategy_missing_font_fallback(self):
"""Missing font should fallback to forward strategy."""
longtext = get_longtext_module()
strategy = get_strategy_module()
app = FakeApp()
stage = longtext.LongTextProcessStage(app)
# Use non-existent font path
pipeline_config = make_longtext_config(strategy='image')
# On non-Windows without font, should fallback to forward
await stage.initialize(pipeline_config)
# Should have initialized (possibly with fallback strategy)
if stage.strategy_impl is not None:
assert isinstance(stage.strategy_impl, strategy.LongTextStrategy)
+321
View File
@@ -0,0 +1,321 @@
"""
Unit tests for ConversationMessageTruncator (msgtrun) pipeline stage.
Tests cover:
- Normal truncation behavior based on max-round
- Boundary length handling
- Empty message handling
- Multi-message chain truncation
"""
from __future__ import annotations
import pytest
from importlib import import_module
from tests.factories import (
FakeApp,
text_query,
)
import langbot_plugin.api.entities.builtin.provider.message as provider_message
def get_msgtrun_module():
"""Lazy import to avoid circular import issues."""
# Import pipelinemgr first to trigger stage registration
import_module('langbot.pkg.pipeline.pipelinemgr')
return import_module('langbot.pkg.pipeline.msgtrun.msgtrun')
def get_truncator_module():
"""Lazy import for truncator base."""
return import_module('langbot.pkg.pipeline.msgtrun.truncator')
def get_entities_module():
"""Lazy import for pipeline entities."""
return import_module('langbot.pkg.pipeline.entities')
def get_round_truncator_module():
"""Lazy import for round truncator."""
return import_module('langbot.pkg.pipeline.msgtrun.truncators.round')
def make_truncate_config(max_round: int = 5):
"""Create a pipeline config with max-round setting."""
return {
'ai': {
'local-agent': {
'max-round': max_round,
}
}
}
class TestConversationMessageTruncatorInit:
"""Tests for ConversationMessageTruncator initialization."""
@pytest.mark.asyncio
async def test_initialize_round_truncator(self):
"""Initialize should select 'round' truncator by default."""
msgtrun = get_msgtrun_module()
truncator = get_truncator_module()
app = FakeApp()
stage = msgtrun.ConversationMessageTruncator(app)
pipeline_config = make_truncate_config()
await stage.initialize(pipeline_config)
assert stage.trun is not None
assert isinstance(stage.trun, truncator.Truncator)
@pytest.mark.asyncio
async def test_initialize_unknown_truncator_raises(self):
"""Initialize with unknown truncator method should raise ValueError."""
msgtrun = get_msgtrun_module()
truncator = get_truncator_module()
# Save original preregistered_truncators
original_truncators = truncator.preregistered_truncators.copy()
try:
# Clear registered truncators to simulate unknown method
truncator.preregistered_truncators = []
app = FakeApp()
stage = msgtrun.ConversationMessageTruncator(app)
pipeline_config = make_truncate_config()
with pytest.raises(ValueError, match='Unknown truncator'):
await stage.initialize(pipeline_config)
finally:
# Restore original truncators
truncator.preregistered_truncators = original_truncators
class TestRoundTruncatorProcess:
"""Tests for RoundTruncator truncation behavior."""
@pytest.mark.asyncio
async def test_truncate_within_limit(self):
"""Messages within max-round limit should not be truncated."""
msgtrun = get_msgtrun_module()
entities = get_entities_module()
app = FakeApp()
stage = msgtrun.ConversationMessageTruncator(app)
pipeline_config = make_truncate_config(max_round=5)
await stage.initialize(pipeline_config)
# Create query with 3 messages (within limit)
query = text_query("current message")
query.pipeline_config = pipeline_config
query.messages = [
provider_message.Message(role='user', content='message 1'),
provider_message.Message(role='assistant', content='response 1'),
provider_message.Message(role='user', content='message 2'),
provider_message.Message(role='assistant', content='response 2'),
provider_message.Message(role='user', content='current message'),
]
result = await stage.process(query, 'ConversationMessageTruncator')
assert result.result_type == entities.ResultType.CONTINUE
# All messages should be preserved
assert len(result.new_query.messages) == 5
@pytest.mark.asyncio
async def test_truncate_exceeds_limit(self):
"""Messages exceeding max-round should be truncated precisely.
Algorithm: traverse backwards, collect while current_round < max_round, count user messages as rounds.
For max_round=2 with 7 messages (u1, a1, u2, a2, u3, a3, u_current):
- Iterate: u_current(r=0<2, collect, r=1), a3(r=1<2, collect), u3(r=1<2, collect, r=2)
- a2: r=2 not < 2 → break
- Collected reverse: [u_current, a3, u3]
- Reversed: [u3, a3, u_current] = 3 messages
"""
msgtrun = get_msgtrun_module()
entities = get_entities_module()
app = FakeApp()
stage = msgtrun.ConversationMessageTruncator(app)
pipeline_config = make_truncate_config(max_round=2) # Only keep 2 rounds
await stage.initialize(pipeline_config)
# Create query with many messages exceeding limit
# 7 messages = 3 full rounds + 1 current user
query = text_query("current message")
query.pipeline_config = pipeline_config
query.messages = [
provider_message.Message(role='user', content='message 1'),
provider_message.Message(role='assistant', content='response 1'),
provider_message.Message(role='user', content='message 2'),
provider_message.Message(role='assistant', content='response 2'),
provider_message.Message(role='user', content='message 3'),
provider_message.Message(role='assistant', content='response 3'),
provider_message.Message(role='user', content='current message'),
]
result = await stage.process(query, 'ConversationMessageTruncator')
assert result.result_type == entities.ResultType.CONTINUE
# Should keep exactly 3 messages: message3, response3, current message
messages = result.new_query.messages
assert len(messages) == 3
# Verify exact message content
assert messages[0].role == 'user'
assert messages[0].content == 'message 3'
assert messages[1].role == 'assistant'
assert messages[1].content == 'response 3'
assert messages[2].role == 'user'
assert messages[2].content == 'current message'
@pytest.mark.asyncio
async def test_truncate_empty_messages(self):
"""Empty messages list should return empty list."""
msgtrun = get_msgtrun_module()
entities = get_entities_module()
app = FakeApp()
stage = msgtrun.ConversationMessageTruncator(app)
pipeline_config = make_truncate_config()
await stage.initialize(pipeline_config)
query = text_query("hello")
query.pipeline_config = pipeline_config
query.messages = []
result = await stage.process(query, 'ConversationMessageTruncator')
assert result.result_type == entities.ResultType.CONTINUE
assert len(result.new_query.messages) == 0
@pytest.mark.asyncio
async def test_truncate_single_message(self):
"""Single message should be preserved."""
msgtrun = get_msgtrun_module()
entities = get_entities_module()
app = FakeApp()
stage = msgtrun.ConversationMessageTruncator(app)
pipeline_config = make_truncate_config()
await stage.initialize(pipeline_config)
query = text_query("hello")
query.pipeline_config = pipeline_config
query.messages = [
provider_message.Message(role='user', content='hello'),
]
result = await stage.process(query, 'ConversationMessageTruncator')
assert result.result_type == entities.ResultType.CONTINUE
assert len(result.new_query.messages) == 1
@pytest.mark.asyncio
async def test_truncate_preserves_order(self):
"""Truncation should preserve message order."""
msgtrun = get_msgtrun_module()
entities = get_entities_module()
app = FakeApp()
stage = msgtrun.ConversationMessageTruncator(app)
pipeline_config = make_truncate_config(max_round=2)
await stage.initialize(pipeline_config)
query = text_query("current")
query.pipeline_config = pipeline_config
query.messages = [
provider_message.Message(role='user', content='user1'),
provider_message.Message(role='assistant', content='asst1'),
provider_message.Message(role='user', content='user2'),
provider_message.Message(role='assistant', content='asst2'),
provider_message.Message(role='user', content='user3'),
]
result = await stage.process(query, 'ConversationMessageTruncator')
assert result.result_type == entities.ResultType.CONTINUE
messages = result.new_query.messages
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):
"""max-round=1 should only keep last user message."""
msgtrun = get_msgtrun_module()
entities = get_entities_module()
app = FakeApp()
stage = msgtrun.ConversationMessageTruncator(app)
pipeline_config = make_truncate_config(max_round=1)
await stage.initialize(pipeline_config)
query = text_query("current")
query.pipeline_config = pipeline_config
query.messages = [
provider_message.Message(role='user', content='old1'),
provider_message.Message(role='assistant', content='old1_resp'),
provider_message.Message(role='user', content='current'),
]
result = await stage.process(query, 'ConversationMessageTruncator')
assert result.result_type == entities.ResultType.CONTINUE
messages = result.new_query.messages
assert [(msg.role, msg.content) for msg in messages] == [('user', 'current')]
class TestRoundTruncatorDirect:
"""Direct tests for RoundTruncator class."""
@pytest.mark.asyncio
async def test_round_truncator_direct_process(self):
"""Test RoundTruncator truncate method directly."""
truncator_mod = get_truncator_module()
app = FakeApp()
# Get the RoundTruncator class from preregistered
for trun_cls in truncator_mod.preregistered_truncators:
if trun_cls.name == 'round':
trun = trun_cls(app)
break
query = text_query("hello")
query.pipeline_config = make_truncate_config(max_round=3)
query.messages = [
provider_message.Message(role='user', content='m1'),
provider_message.Message(role='assistant', content='r1'),
provider_message.Message(role='user', content='m2'),
provider_message.Message(role='assistant', content='r2'),
provider_message.Message(role='user', content='hello'),
]
result = await trun.truncate(query)
assert result is not None
assert hasattr(result, 'messages')
+35 -23
View File
@@ -19,13 +19,22 @@ 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
import langbot_plugin.api.entities.builtin.provider.message as provider_message
from langbot.pkg.provider.runners.n8nsvapi import N8nServiceAPIRunner
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
# ---------------------------------------------------------------------------
@@ -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'
# ---------------------------------------------------------------------------
@@ -0,0 +1,43 @@
from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.api.http.service.pipeline import PipelineService
@pytest.mark.asyncio
async def test_update_pipeline_filters_protected_fields_without_mutating_input(mock_app):
service = PipelineService(mock_app)
loaded_pipeline = Mock()
service.get_pipeline = AsyncMock(return_value=loaded_pipeline)
bot = Mock(uuid='bot-uuid')
bot_result = Mock(all=Mock(return_value=[bot]))
mock_app.persistence_mgr.execute_async = AsyncMock(side_effect=[None, bot_result])
mock_app.bot_service = Mock(update_bot=AsyncMock())
mock_app.pipeline_mgr = Mock(remove_pipeline=AsyncMock(), load_pipeline=AsyncMock())
mock_app.sess_mgr.session_list = []
pipeline_data = {
'uuid': 'caller-uuid',
'for_version': '1.0.0',
'stages': ['CallerStage'],
'is_default': True,
'name': 'Updated pipeline',
}
original_pipeline_data = pipeline_data.copy()
await service.update_pipeline('pipeline-uuid', pipeline_data)
assert pipeline_data == original_pipeline_data
update_stmt = mock_app.persistence_mgr.execute_async.await_args_list[0].args[0]
updated_fields = {getattr(field, 'key', str(field)) for field in update_stmt._values}
assert updated_fields == {'name'}
mock_app.bot_service.update_bot.assert_awaited_once_with(
'bot-uuid',
{'use_pipeline_name': 'Updated pipeline'},
)
mock_app.pipeline_mgr.remove_pipeline.assert_awaited_once_with('pipeline-uuid')
mock_app.pipeline_mgr.load_pipeline.assert_awaited_once_with(loaded_pipeline)
+12 -18
View File
@@ -119,30 +119,24 @@ async def test_remove_pipeline(mock_app):
@pytest.mark.asyncio
async def test_runtime_pipeline_execute(mock_app, sample_query):
"""Test runtime pipeline execution"""
"""Test runtime pipeline execution with real Pydantic models."""
pipelinemgr = get_pipelinemgr_module()
stage = get_stage_module()
persistence_pipeline = get_persistence_pipeline_module()
entities = get_entities_module()
# Create mock stage that returns a simple result dict (avoiding Pydantic validation)
mock_result = Mock()
mock_result.result_type = Mock()
mock_result.result_type.value = 'CONTINUE' # Simulate enum value
mock_result.new_query = sample_query
mock_result.user_notice = ''
mock_result.console_notice = ''
mock_result.debug_notice = ''
mock_result.error_notice = ''
# Make it look like ResultType.CONTINUE
from unittest.mock import MagicMock
CONTINUE = MagicMock()
CONTINUE.__eq__ = lambda self, other: True # Always equal for comparison
mock_result.result_type = CONTINUE
# Create result using real Pydantic model (not Mock) to ensure validation
real_result = entities.StageProcessResult(
result_type=entities.ResultType.CONTINUE,
new_query=sample_query,
user_notice='',
console_notice='',
debug_notice='',
error_notice='',
)
mock_stage = Mock(spec=stage.PipelineStage)
mock_stage.process = AsyncMock(return_value=mock_result)
mock_stage.process = AsyncMock(return_value=real_result)
# Create stage container
stage_container = pipelinemgr.StageInstContainer(inst_name='TestStage', inst=mock_stage)
+290
View File
@@ -0,0 +1,290 @@
"""
Unit tests for QueryPool.
Tests query management, ID generation, and async context handling.
"""
from __future__ import annotations
import pytest
from unittest.mock import Mock, patch
from langbot.pkg.pipeline.pool import QueryPool
pytestmark = pytest.mark.asyncio
class TestQueryPoolInit:
"""Tests for QueryPool initialization."""
def test_init_creates_empty_pool(self):
"""QueryPool initializes with empty lists."""
pool = QueryPool()
assert pool.queries == []
assert pool.cached_queries == {}
assert pool.query_id_counter == 0
assert pool.pool_lock is not None
assert pool.condition is not None
def test_init_counter_starts_at_zero(self):
"""Counter starts at zero."""
pool = QueryPool()
assert pool.query_id_counter == 0
class TestQueryPoolAddQuery:
"""Tests for add_query method."""
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
mock_query = Mock()
mock_query.query_id = 0
mock_query.bot_uuid = 'test-bot-uuid'
mock_query.launcher_id = 12345
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
MockQuery.return_value = mock_query
await pool.add_query(
bot_uuid='test-bot-uuid',
launcher_type=Mock(),
launcher_id=12345,
sender_id=12345,
message_event=Mock(),
message_chain=Mock(),
adapter=Mock(),
)
# Query is added to list and cache
assert pool.queries[0] is mock_query
assert pool.cached_queries[0] is mock_query
assert mock_query.query_id == 0
async def test_add_query_increments_counter(self):
"""Each add_query increments the counter."""
pool = QueryPool()
mock_query1 = Mock()
mock_query1.query_id = 0
mock_query2 = Mock()
mock_query2.query_id = 1
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
MockQuery.side_effect = [mock_query1, mock_query2]
await pool.add_query(
bot_uuid='bot1',
launcher_type=Mock(),
launcher_id=1,
sender_id=1,
message_event=Mock(),
message_chain=Mock(),
adapter=Mock(),
)
await pool.add_query(
bot_uuid='bot2',
launcher_type=Mock(),
launcher_id=2,
sender_id=2,
message_event=Mock(),
message_chain=Mock(),
adapter=Mock(),
)
assert pool.query_id_counter == 2
assert pool.queries[0].query_id == 0
assert pool.queries[1].query_id == 1
async def test_add_query_appends_to_list(self):
"""Query is appended to queries list."""
pool = QueryPool()
mock_query = Mock()
mock_query.query_id = 0
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
MockQuery.return_value = mock_query
await pool.add_query(
bot_uuid='bot1',
launcher_type=Mock(),
launcher_id=1,
sender_id=1,
message_event=Mock(),
message_chain=Mock(),
adapter=Mock(),
)
assert len(pool.queries) == 1
assert pool.queries[0] is mock_query
async def test_add_query_caches_query(self):
"""Query is cached by query_id."""
pool = QueryPool()
mock_query = Mock()
mock_query.query_id = 0
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
MockQuery.return_value = mock_query
await pool.add_query(
bot_uuid='bot1',
launcher_type=Mock(),
launcher_id=1,
sender_id=1,
message_event=Mock(),
message_chain=Mock(),
adapter=Mock(),
)
assert 0 in pool.cached_queries
assert pool.cached_queries[0] is mock_query
async def test_add_query_with_pipeline_uuid(self):
"""Query can have pipeline_uuid set."""
pool = QueryPool()
mock_query = Mock()
mock_query.query_id = 0
mock_query.pipeline_uuid = 'test-pipeline-uuid'
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
MockQuery.return_value = mock_query
await pool.add_query(
bot_uuid='bot1',
launcher_type=Mock(),
launcher_id=1,
sender_id=1,
message_event=Mock(),
message_chain=Mock(),
adapter=Mock(),
pipeline_uuid='test-pipeline-uuid',
)
# Verify pipeline_uuid was passed to Query constructor
call_kwargs = MockQuery.call_args[1]
assert call_kwargs['pipeline_uuid'] == 'test-pipeline-uuid'
async def test_add_query_sets_routed_by_rule_variable(self):
"""Query has _routed_by_rule variable."""
pool = QueryPool()
mock_query = Mock()
mock_query.query_id = 0
mock_query.variables = {'_routed_by_rule': True}
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
MockQuery.return_value = mock_query
await pool.add_query(
bot_uuid='bot1',
launcher_type=Mock(),
launcher_id=1,
sender_id=1,
message_event=Mock(),
message_chain=Mock(),
adapter=Mock(),
routed_by_rule=True,
)
# Verify variables includes _routed_by_rule
call_kwargs = MockQuery.call_args[1]
assert call_kwargs['variables']['_routed_by_rule'] is True
async def test_add_query_notifier_condition(self):
"""add_query notifies waiting consumers."""
pool = QueryPool()
mock_query = Mock()
mock_query.query_id = 0
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
MockQuery.return_value = mock_query
# Track if notify_all was called
original_notify = pool.condition.notify_all
notify_called = []
def mock_notify():
notify_called.append(True)
return original_notify()
pool.condition.notify_all = mock_notify
await pool.add_query(
bot_uuid='bot1',
launcher_type=Mock(),
launcher_id=1,
sender_id=1,
message_event=Mock(),
message_chain=Mock(),
adapter=Mock(),
)
assert len(notify_called) == 1
class TestQueryPoolContext:
"""Tests for async context manager."""
async def test_aenter_acquires_lock(self):
"""__aenter__ acquires the pool lock."""
pool = QueryPool()
async with pool as p:
# Lock is acquired
assert pool.pool_lock.locked()
assert p is pool
async def test_aexit_releases_lock(self):
"""__aexit__ releases the pool lock."""
pool = QueryPool()
async with pool:
pass
# Lock is released after context exit
assert not pool.pool_lock.locked()
class TestQueryPoolEdgeCases:
"""Tests for edge cases."""
async def test_multiple_queries_cached_correctly(self):
"""Multiple queries are cached separately."""
pool = QueryPool()
mock_queries = []
for i in range(5):
q = Mock()
q.query_id = i
mock_queries.append(q)
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
MockQuery.side_effect = mock_queries
for i in range(5):
await pool.add_query(
bot_uuid=f'bot{i}',
launcher_type=Mock(),
launcher_id=i,
sender_id=i,
message_event=Mock(),
message_chain=Mock(),
adapter=Mock(),
)
# All cached
assert len(pool.cached_queries) == 5
# Each query is cached by its ID
for i in range(5):
assert pool.cached_queries[i] is mock_queries[i]
+430
View File
@@ -0,0 +1,430 @@
"""
Unit tests for PreProcessor pipeline stage.
Tests cover preprocessing behavior including:
- Normal text message processing
- Empty message handling
- Unsupported message segment handling
- Image/file segment behavior
- Model selection and fallback
"""
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock
from importlib import import_module
from tests.factories import (
FakeApp,
text_query,
empty_query,
image_query,
group_text_query,
)
def get_preproc_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.pipeline.preproc.preproc')
def get_entities_module():
"""Lazy import for pipeline entities."""
return import_module('langbot.pkg.pipeline.entities')
class TestPreProcessorNormalText:
"""Tests for normal text message preprocessing."""
@pytest.mark.asyncio
async def test_normal_text_continues(self):
"""Normal text message should continue pipeline."""
preproc = get_preproc_module()
entities = get_entities_module()
app = FakeApp()
# Mock session manager to return a session
mock_session = Mock()
mock_session.launcher_type = Mock(value='person')
mock_session.launcher_id = 12345
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
# Mock conversation
mock_conversation = Mock()
mock_conversation.prompt = Mock()
mock_conversation.prompt.messages = []
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
mock_conversation.messages = []
mock_conversation.update_time = Mock()
mock_conversation.uuid = None
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
# Mock model manager
mock_model = Mock()
mock_model.model_entity = Mock()
mock_model.model_entity.uuid = 'test-model-uuid'
mock_model.model_entity.abilities = ['func_call', 'vision']
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
# Mock tool manager
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
# Mock plugin connector
mock_event_ctx = Mock()
mock_event_ctx.event = Mock()
mock_event_ctx.event.default_prompt = []
mock_event_ctx.event.prompt = []
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
stage = preproc.PreProcessor(app)
query = text_query("hello world")
result = await stage.process(query, 'PreProcessor')
assert result.result_type == entities.ResultType.CONTINUE
assert result.new_query is not None
@pytest.mark.asyncio
async def test_normal_text_sets_user_message(self):
"""PreProcessor should set user_message from text content."""
preproc = get_preproc_module()
app = FakeApp()
mock_session = Mock()
mock_session.launcher_type = Mock(value='person')
mock_session.launcher_id = 12345
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
mock_conversation = Mock()
mock_conversation.prompt = Mock(messages=[])
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
mock_conversation.messages = []
mock_conversation.uuid = None
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
mock_model = Mock()
mock_model.model_entity = Mock(uuid='test-model', abilities=['func_call'])
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
mock_event_ctx = Mock()
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
stage = preproc.PreProcessor(app)
query = text_query("test message")
result = await stage.process(query, 'PreProcessor')
assert result.new_query.user_message is not None
assert result.new_query.user_message.role == 'user'
class TestPreProcessorEmptyMessage:
"""Tests for empty message handling."""
@pytest.mark.asyncio
async def test_empty_message_continues(self):
"""Empty message should follow expected behavior."""
preproc = get_preproc_module()
entities = get_entities_module()
app = FakeApp()
mock_session = Mock()
mock_session.launcher_type = Mock(value='person')
mock_session.launcher_id = 12345
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
mock_conversation = Mock()
mock_conversation.prompt = Mock(messages=[])
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
mock_conversation.messages = []
mock_conversation.uuid = None
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
mock_event_ctx = Mock()
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
stage = preproc.PreProcessor(app)
query = empty_query()
result = await stage.process(query, 'PreProcessor')
# 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
assert result.new_query.user_message.content == []
class TestPreProcessorImageSegment:
"""Tests for image segment handling."""
@pytest.mark.asyncio
async def test_image_with_vision_model(self):
"""Image should be included when model supports vision."""
preproc = get_preproc_module()
app = FakeApp()
mock_session = Mock()
mock_session.launcher_type = Mock(value='person')
mock_session.launcher_id = 12345
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
mock_conversation = Mock()
mock_conversation.prompt = Mock(messages=[])
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
mock_conversation.messages = []
mock_conversation.uuid = None
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
# Model with vision support
mock_model = Mock()
mock_model.model_entity = Mock(uuid='vision-model', abilities=['func_call', 'vision'])
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
mock_event_ctx = Mock()
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
stage = preproc.PreProcessor(app)
# Image query with base64
query = image_query(text="look at this", url=None)
# Set base64 on the image component
import langbot_plugin.api.entities.builtin.platform.message as platform_message
chain = platform_message.MessageChain([
platform_message.Plain(text="look at this"),
platform_message.Image(base64="data:image/png;base64,abc123"),
])
query.message_chain = chain
result = await stage.process(query, 'PreProcessor')
assert result.result_type == preproc.entities.ResultType.CONTINUE
# User message should have content
assert result.new_query.user_message.content is not None
@pytest.mark.asyncio
async def test_image_without_vision_model(self):
"""Image should be excluded when model doesn't support vision."""
preproc = get_preproc_module()
app = FakeApp()
mock_session = Mock()
mock_session.launcher_type = Mock(value='person')
mock_session.launcher_id = 12345
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
mock_conversation = Mock()
mock_conversation.prompt = Mock(messages=[])
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
mock_conversation.messages = []
mock_conversation.uuid = None
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
# Model WITHOUT vision support
mock_model = Mock()
mock_model.model_entity = Mock(uuid='text-only-model', abilities=['func_call'])
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
mock_event_ctx = Mock()
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
stage = preproc.PreProcessor(app)
query = image_query(text="describe this")
result = await stage.process(query, 'PreProcessor')
assert result.result_type == preproc.entities.ResultType.CONTINUE
class TestPreProcessorModelSelection:
"""Tests for model selection and fallback behavior."""
@pytest.mark.asyncio
async def test_primary_model_selected(self):
"""Primary model UUID should be set in query."""
preproc = get_preproc_module()
app = FakeApp()
mock_session = Mock()
mock_session.launcher_type = Mock(value='person')
mock_session.launcher_id = 12345
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
mock_conversation = Mock()
mock_conversation.prompt = Mock(messages=[])
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
mock_conversation.messages = []
mock_conversation.uuid = None
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
mock_model = Mock()
mock_model.model_entity = Mock(uuid='primary-model-uuid', abilities=['func_call'])
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
mock_event_ctx = Mock()
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
stage = preproc.PreProcessor(app)
query = text_query("hello")
# Set pipeline config with primary model
query.pipeline_config = {
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {
'model': {'primary': 'primary-model-uuid', 'fallbacks': []},
'prompt': 'default',
},
},
'output': {'misc': {'at-sender': False}},
'trigger': {'misc': {}},
}
result = await stage.process(query, 'PreProcessor')
assert result.new_query.use_llm_model_uuid == 'primary-model-uuid'
@pytest.mark.asyncio
async def test_fallback_models_resolved(self):
"""Fallback model UUIDs should be resolved and stored."""
preproc = get_preproc_module()
app = FakeApp()
mock_session = Mock()
mock_session.launcher_type = Mock(value='person')
mock_session.launcher_id = 12345
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
mock_conversation = Mock()
mock_conversation.prompt = Mock(messages=[])
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
mock_conversation.messages = []
mock_conversation.uuid = None
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
# Primary model
mock_primary = Mock()
mock_primary.model_entity = Mock(uuid='primary-uuid', abilities=['func_call'])
# Fallback model
mock_fallback = Mock()
mock_fallback.model_entity = Mock(uuid='fallback-uuid', abilities=['func_call'])
async def mock_get_model(uuid):
if uuid == 'primary-uuid':
return mock_primary
elif uuid == 'fallback-uuid':
return mock_fallback
raise ValueError(f'Model {uuid} not found')
app.model_mgr.get_model_by_uuid = AsyncMock(side_effect=mock_get_model)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
mock_event_ctx = Mock()
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
stage = preproc.PreProcessor(app)
query = text_query("hello")
query.pipeline_config = {
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {
'model': {'primary': 'primary-uuid', 'fallbacks': ['fallback-uuid']},
'prompt': 'default',
},
},
'output': {'misc': {'at-sender': False}},
'trigger': {'misc': {}},
}
result = await stage.process(query, 'PreProcessor')
assert '_fallback_model_uuids' in result.new_query.variables
assert 'fallback-uuid' in result.new_query.variables['_fallback_model_uuids']
class TestPreProcessorVariables:
"""Tests for query variable extraction."""
@pytest.mark.asyncio
async def test_variables_set_from_query(self):
"""PreProcessor should set variables from query context."""
preproc = get_preproc_module()
app = FakeApp()
mock_session = Mock()
mock_session.launcher_type = Mock(value='person')
mock_session.launcher_id = 12345
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
mock_conversation = Mock()
mock_conversation.prompt = Mock(messages=[])
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
mock_conversation.messages = []
mock_conversation.uuid = 'conv-123'
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
mock_event_ctx = Mock()
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
stage = preproc.PreProcessor(app)
query = text_query("hello", sender_id=67890)
result = await stage.process(query, 'PreProcessor')
variables = result.new_query.variables
assert 'launcher_type' in variables
assert 'launcher_id' in variables
assert 'sender_id' in variables
assert variables['sender_id'] == 67890
assert 'user_message_text' in variables
@pytest.mark.asyncio
async def test_group_variables_include_group_name(self):
"""Group messages should include group_name variable."""
preproc = get_preproc_module()
app = FakeApp()
mock_session = Mock()
mock_session.launcher_type = Mock(value='group')
mock_session.launcher_id = 99999
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
mock_conversation = Mock()
mock_conversation.prompt = Mock(messages=[])
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
mock_conversation.messages = []
mock_conversation.uuid = None
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
mock_event_ctx = Mock()
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
stage = preproc.PreProcessor(app)
query = group_text_query("hello", group_id=99999)
result = await stage.process(query, 'PreProcessor')
variables = result.new_query.variables
assert 'group_name' in variables
assert 'sender_name' in variables
@@ -0,0 +1,75 @@
"""
QueryPool unit tests
"""
import pytest
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from langbot.pkg.pipeline.pool import QueryPool
class DummyEventLogger(abstract_platform_logger.AbstractEventLogger):
async def info(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def debug(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def warning(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def error(self, text, images=None, message_session_id=None, no_throw=True):
pass
class DummyAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def send_message(self, target_type, target_id, message):
pass
async def reply_message(self, message_source, message, quote_origin=False):
pass
def register_listener(self, event_type, callback):
pass
def unregister_listener(self, event_type, callback):
pass
async def run_async(self):
pass
async def kill(self):
return True
@pytest.mark.asyncio
async def test_add_query_returns_created_query_and_preserves_side_effects(
sample_message_chain,
sample_message_event,
):
"""add_query returns the created Query while keeping pool/cache updates."""
query_pool = QueryPool()
adapter = DummyAdapter(config={}, logger=DummyEventLogger())
query = await query_pool.add_query(
bot_uuid='test-bot-uuid',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=67890,
message_event=sample_message_event,
message_chain=sample_message_chain,
adapter=adapter,
pipeline_uuid='test-pipeline-uuid',
routed_by_rule=True,
)
assert query is query_pool.queries[0]
assert query_pool.cached_queries[0] is query
assert query_pool.query_id_counter == 1
assert query.query_id == 0
assert query.bot_uuid == 'test-bot-uuid'
assert query.pipeline_uuid == 'test-pipeline-uuid'
assert query.variables == {'_routed_by_rule': True}
+281
View File
@@ -5,6 +5,8 @@ Tests the actual RateLimit implementation from pkg.pipeline.ratelimit
"""
import pytest
import asyncio
import time
from unittest.mock import AsyncMock, Mock, patch
from importlib import import_module
import langbot_plugin.api.entities.builtin.provider.session as provider_session
@@ -19,6 +21,285 @@ def get_modules():
return ratelimit, entities, algo_module
def get_fixedwin_module():
"""Lazy import of FixedWindowAlgo"""
return import_module('langbot.pkg.pipeline.ratelimit.algos.fixedwin')
class TestFixedWindowAlgo:
"""Tests for the actual FixedWindowAlgo implementation.
IMPORTANT: These tests verify the real algorithm logic, not mocks.
"""
@pytest.fixture
def mock_app_for_algo(self):
"""Create mock app for algorithm initialization."""
mock_app = Mock()
mock_app.logger = Mock()
return mock_app
@pytest.fixture
def sample_query_with_rate_limit(self, sample_query):
"""Create query with rate limit configuration."""
sample_query.pipeline_config = {
'safety': {
'rate-limit': {
'window-length': 60, # 60 seconds window
'limitation': 10, # 10 requests per window
'strategy': 'drop',
}
}
}
return sample_query
@pytest.mark.asyncio
async def test_fixedwin_algo_initialization(self, mock_app_for_algo):
"""Test that FixedWindowAlgo initializes correctly."""
fixedwin = get_fixedwin_module()
algo = fixedwin.FixedWindowAlgo(mock_app_for_algo)
await algo.initialize()
assert algo.containers_lock is not None
assert algo.containers == {}
@pytest.mark.asyncio
async def test_fixedwin_within_limit_returns_true(self, mock_app_for_algo, sample_query_with_rate_limit):
"""Test that requests within limit are allowed."""
fixedwin = get_fixedwin_module()
algo = fixedwin.FixedWindowAlgo(mock_app_for_algo)
await algo.initialize()
# Make requests within limit
for i in range(10):
result = await algo.require_access(
sample_query_with_rate_limit,
provider_session.LauncherTypes.PERSON,
'12345'
)
assert result is True, f"Request {i+1} should be allowed"
@pytest.mark.asyncio
async def test_fixedwin_exceeds_limit_drop_strategy(self, mock_app_for_algo, sample_query_with_rate_limit):
"""Test that exceeding limit with 'drop' strategy returns False."""
fixedwin = get_fixedwin_module()
algo = fixedwin.FixedWindowAlgo(mock_app_for_algo)
await algo.initialize()
# Exhaust the limit
for i in range(10):
await algo.require_access(
sample_query_with_rate_limit,
provider_session.LauncherTypes.PERSON,
'12345'
)
# Next request should be denied
result = await algo.require_access(
sample_query_with_rate_limit,
provider_session.LauncherTypes.PERSON,
'12345'
)
assert result is False, "Request exceeding limit should be denied"
@pytest.mark.asyncio
async def test_fixedwin_different_sessions_isolated(self, mock_app_for_algo, sample_query_with_rate_limit):
"""Test that different sessions have independent rate limits."""
fixedwin = get_fixedwin_module()
algo = fixedwin.FixedWindowAlgo(mock_app_for_algo)
await algo.initialize()
# Exhaust limit for session 1
for i in range(10):
await algo.require_access(
sample_query_with_rate_limit,
provider_session.LauncherTypes.PERSON,
'session1'
)
# Session 2 should still have its own limit
result = await algo.require_access(
sample_query_with_rate_limit,
provider_session.LauncherTypes.PERSON,
'session2'
)
assert result is True, "Different session should have independent limit"
@pytest.mark.asyncio
async def test_fixedwin_limit_one_request(self, mock_app_for_algo, sample_query):
"""Test with limitation=1 allows only one request."""
fixedwin = get_fixedwin_module()
sample_query.pipeline_config = {
'safety': {
'rate-limit': {
'window-length': 60,
'limitation': 1, # Only 1 request allowed
'strategy': 'drop',
}
}
}
algo = fixedwin.FixedWindowAlgo(mock_app_for_algo)
await algo.initialize()
# First request allowed
result1 = await algo.require_access(
sample_query,
provider_session.LauncherTypes.PERSON,
'12345'
)
assert result1 is True
# Second request denied
result2 = await algo.require_access(
sample_query,
provider_session.LauncherTypes.PERSON,
'12345'
)
assert result2 is False
@pytest.mark.asyncio
async def test_fixedwin_container_persists(self, mock_app_for_algo, sample_query_with_rate_limit):
"""Test that container is created and persists across requests."""
fixedwin = get_fixedwin_module()
algo = fixedwin.FixedWindowAlgo(mock_app_for_algo)
await algo.initialize()
# First request creates container
await algo.require_access(
sample_query_with_rate_limit,
provider_session.LauncherTypes.PERSON,
'12345'
)
# Key format: 'LauncherTypes.PERSON_12345' (enum string representation)
expected_key = 'LauncherTypes.PERSON_12345'
assert expected_key in algo.containers
container = algo.containers[expected_key]
# Container should have records
assert len(container.records) > 0
@pytest.mark.asyncio
async def test_fixedwin_new_window_clears_records(self, mock_app_for_algo, sample_query):
"""Test that a new time window starts fresh records.
This test verifies the window calculation logic:
- Records are keyed by window start timestamp
- When window advances, new key is created
"""
fixedwin = get_fixedwin_module()
# Use a very short window for testing
sample_query.pipeline_config = {
'safety': {
'rate-limit': {
'window-length': 1, # 1 second window for fast test
'limitation': 5,
'strategy': 'drop',
}
}
}
algo = fixedwin.FixedWindowAlgo(mock_app_for_algo)
await algo.initialize()
# Make requests in current window
now = int(time.time())
window_start = now - now % 1
for i in range(5):
await algo.require_access(sample_query, provider_session.LauncherTypes.PERSON, 'test')
# Key format: 'LauncherTypes.PERSON_test'
expected_key = 'LauncherTypes.PERSON_test'
container = algo.containers[expected_key]
assert window_start in container.records
assert container.records[window_start] == 5
# Wait for next window (1 second)
await asyncio.sleep(1.1)
# New request should be allowed (new window)
result = await algo.require_access(sample_query, provider_session.LauncherTypes.PERSON, 'test')
assert result is True, "New window should allow new requests"
@pytest.mark.asyncio
async def test_fixedwin_wait_strategy_blocks_until_next_window(self, mock_app_for_algo, sample_query):
"""Test that 'wait' strategy blocks until next window.
NOTE: This test is timing-sensitive and may take ~1 second.
"""
fixedwin = get_fixedwin_module()
# Use 1-second window for testability
sample_query.pipeline_config = {
'safety': {
'rate-limit': {
'window-length': 1,
'limitation': 1, # Only 1 request per second
'strategy': 'wait',
}
}
}
algo = fixedwin.FixedWindowAlgo(mock_app_for_algo)
await algo.initialize()
# First request allowed
start_time = time.time()
result1 = await algo.require_access(
sample_query,
provider_session.LauncherTypes.PERSON,
'wait_test'
)
assert result1 is True
# Exhaust limit
await algo.require_access(sample_query, provider_session.LauncherTypes.PERSON, 'wait_test')
# Third request should wait and then succeed
result3 = await algo.require_access(
sample_query,
provider_session.LauncherTypes.PERSON,
'wait_test'
)
elapsed = time.time() - start_time
assert result3 is True, "After wait, request should succeed"
# Should have waited approximately until next window
# With 1-second window, elapsed should be > 0.5 second (allowing for timing variance)
# Note: This is a timing-sensitive test, so we use a generous tolerance
assert elapsed >= 0.5, f"Should have waited for next window, elapsed={elapsed:.2f}s"
@pytest.mark.asyncio
async def test_fixedwin_release_access(self, mock_app_for_algo, sample_query_with_rate_limit):
"""Test that release_access does nothing (current implementation)."""
fixedwin = get_fixedwin_module()
algo = fixedwin.FixedWindowAlgo(mock_app_for_algo)
await algo.initialize()
# release_access is empty in current implementation
await algo.release_access(
sample_query_with_rate_limit,
provider_session.LauncherTypes.PERSON,
'12345'
)
# Should not raise or change state
assert 'person_12345' not in algo.containers
# Original mock-based tests for RateLimit stage integration
@pytest.mark.asyncio
async def test_require_access_allowed(mock_app, sample_query):
"""Test RequireRateLimitOccupancy allows access when rate limit is not exceeded"""
-40
View File
@@ -1,40 +0,0 @@
"""
Simple standalone tests to verify test infrastructure
These tests don't import the actual pipeline code to avoid circular import issues
"""
import pytest
from unittest.mock import Mock, AsyncMock
def test_pytest_works():
"""Verify pytest is working"""
assert True
@pytest.mark.asyncio
async def test_async_works():
"""Verify async tests work"""
mock = AsyncMock(return_value=42)
result = await mock()
assert result == 42
def test_mocks_work():
"""Verify mocking works"""
mock = Mock()
mock.return_value = 'test'
assert mock() == 'test'
def test_fixtures_work(mock_app):
"""Verify fixtures are loaded"""
assert mock_app is not None
assert mock_app.logger is not None
assert mock_app.sess_mgr is not None
def test_sample_query(sample_query):
"""Verify sample query fixture works"""
assert sample_query.query_id == 'test-query-id'
assert sample_query.launcher_id == 12345
+476
View File
@@ -0,0 +1,476 @@
"""
Unit tests for ResponseWrapper (wrapper) pipeline stage.
Tests cover:
- MessageChain wrapping
- Command response wrapping
- Plugin response wrapping
- Assistant response wrapping with content/tool_calls
- Plugin event emission and INTERRUPT handling
"""
from __future__ import annotations
import pytest
from unittest.mock import Mock, AsyncMock
from importlib import import_module
from tests.factories import (
FakeApp,
text_query,
)
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session
def get_wrapper_module():
"""Lazy import to avoid circular import issues."""
# Import pipelinemgr first to trigger stage registration
import_module('langbot.pkg.pipeline.pipelinemgr')
return import_module('langbot.pkg.pipeline.wrapper.wrapper')
def get_entities_module():
"""Lazy import for pipeline entities."""
return import_module('langbot.pkg.pipeline.entities')
def make_wrapper_config():
"""Create a pipeline config for wrapper tests."""
return {
'output': {
'misc': {
'at-sender': False,
'quote-origin': False,
'track-function-calls': False,
}
}
}
def make_session():
"""Create a valid Session object for tests."""
return provider_session.Session(
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
use_prompt_name="default",
using_conversation=None,
conversations=[],
)
class TestResponseWrapperInit:
"""Tests for ResponseWrapper initialization."""
@pytest.mark.asyncio
async def test_initialize_passes(self):
"""Initialize should complete without error."""
wrapper = get_wrapper_module()
app = FakeApp()
stage = wrapper.ResponseWrapper(app)
pipeline_config = {}
await stage.initialize(pipeline_config)
class TestResponseWrapperMessageChain:
"""Tests for MessageChain wrapping."""
@pytest.mark.asyncio
async def test_message_chain_direct_append(self):
"""MessageChain in resp_messages should be directly appended."""
wrapper = get_wrapper_module()
entities = get_entities_module()
app = FakeApp()
stage = wrapper.ResponseWrapper(app)
pipeline_config = make_wrapper_config()
await stage.initialize(pipeline_config)
query = text_query("hello")
query.pipeline_config = pipeline_config
query.resp_messages = [
platform_message.MessageChain([platform_message.Plain(text="response")])
]
query.resp_message_chain = []
results = []
async for result in stage.process(query, 'ResponseWrapper'):
results.append(result)
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
assert len(results[0].new_query.resp_message_chain) == 1
class TestResponseWrapperCommand:
"""Tests for command response wrapping."""
@pytest.mark.asyncio
async def test_command_response_prefix(self):
"""Command response should have [bot] prefix."""
wrapper = get_wrapper_module()
entities = get_entities_module()
app = FakeApp()
stage = wrapper.ResponseWrapper(app)
pipeline_config = make_wrapper_config()
await stage.initialize(pipeline_config)
query = text_query("hello")
query.pipeline_config = pipeline_config
query.resp_message_chain = []
# Create a command response message
command_resp = Mock()
command_resp.role = 'command'
command_resp.get_content_platform_message_chain = Mock(
return_value=platform_message.MessageChain([platform_message.Plain(text="Help info")])
)
query.resp_messages = [command_resp]
results = []
async for result in stage.process(query, 'ResponseWrapper'):
results.append(result)
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
# Check that prefix was added (via get_content_platform_message_chain)
command_resp.get_content_platform_message_chain.assert_called_once()
class TestResponseWrapperPlugin:
"""Tests for plugin response wrapping."""
@pytest.mark.asyncio
async def test_plugin_response_direct(self):
"""Plugin response should be wrapped without prefix."""
wrapper = get_wrapper_module()
entities = get_entities_module()
app = FakeApp()
stage = wrapper.ResponseWrapper(app)
pipeline_config = make_wrapper_config()
await stage.initialize(pipeline_config)
query = text_query("hello")
query.pipeline_config = pipeline_config
query.resp_message_chain = []
# Create a plugin response message
plugin_resp = Mock()
plugin_resp.role = 'plugin'
plugin_resp.get_content_platform_message_chain = Mock(
return_value=platform_message.MessageChain([platform_message.Plain(text="Plugin response")])
)
query.resp_messages = [plugin_resp]
results = []
async for result in stage.process(query, 'ResponseWrapper'):
results.append(result)
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
class TestResponseWrapperAssistant:
"""Tests for assistant response wrapping."""
@pytest.mark.asyncio
async def test_assistant_content_response(self):
"""Assistant with content should emit event and wrap."""
wrapper = get_wrapper_module()
entities = get_entities_module()
app = FakeApp()
# Mock session manager to return a valid Session
session = make_session()
app.sess_mgr.get_session = AsyncMock(return_value=session)
# Mock plugin connector - normal event (not prevented)
mock_event_ctx = Mock()
mock_event_ctx.is_prevented_default = Mock(return_value=False)
mock_event_ctx.event = Mock()
mock_event_ctx.event.reply_message_chain = None
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
stage = wrapper.ResponseWrapper(app)
pipeline_config = make_wrapper_config()
await stage.initialize(pipeline_config)
query = text_query("hello")
query.pipeline_config = pipeline_config
query.resp_message_chain = []
# Create assistant response with content
assistant_resp = Mock()
assistant_resp.role = 'assistant'
assistant_resp.content = "Hello back!"
assistant_resp.tool_calls = None
assistant_resp.get_content_platform_message_chain = Mock(
return_value=platform_message.MessageChain([platform_message.Plain(text="Hello back!")])
)
query.resp_messages = [assistant_resp]
results = []
async for result in stage.process(query, 'ResponseWrapper'):
results.append(result)
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
# Event should have been emitted
app.plugin_connector.emit_event.assert_called()
@pytest.mark.asyncio
async def test_assistant_empty_content(self):
"""Assistant with empty content should not emit event."""
wrapper = get_wrapper_module()
app = FakeApp()
app.plugin_connector.emit_event = AsyncMock()
stage = wrapper.ResponseWrapper(app)
pipeline_config = make_wrapper_config()
await stage.initialize(pipeline_config)
query = text_query("hello")
query.pipeline_config = pipeline_config
query.resp_message_chain = []
# Create assistant response with empty content
assistant_resp = Mock()
assistant_resp.role = 'assistant'
assistant_resp.content = None
assistant_resp.tool_calls = None
query.resp_messages = [assistant_resp]
results = []
async for result in stage.process(query, 'ResponseWrapper'):
results.append(result)
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):
"""Assistant with tool_calls should show function call message."""
wrapper = get_wrapper_module()
entities = get_entities_module()
app = FakeApp()
# Mock session manager to return a valid Session
session = make_session()
app.sess_mgr.get_session = AsyncMock(return_value=session)
# Mock plugin connector
mock_event_ctx = Mock()
mock_event_ctx.is_prevented_default = Mock(return_value=False)
mock_event_ctx.event = Mock()
mock_event_ctx.event.reply_message_chain = None
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
stage = wrapper.ResponseWrapper(app)
pipeline_config = make_wrapper_config()
pipeline_config['output']['misc']['track-function-calls'] = True
await stage.initialize(pipeline_config)
query = text_query("hello")
query.pipeline_config = pipeline_config
query.resp_message_chain = []
# Create assistant response with tool_calls
mock_tool_call = Mock()
mock_tool_call.function = Mock()
mock_tool_call.function.name = 'test_function'
assistant_resp = Mock()
assistant_resp.role = 'assistant'
assistant_resp.content = "Processing..."
assistant_resp.tool_calls = [mock_tool_call]
assistant_resp.get_content_platform_message_chain = Mock(
return_value=platform_message.MessageChain([platform_message.Plain(text="Processing...")])
)
query.resp_messages = [assistant_resp]
results = []
async for result in stage.process(query, 'ResponseWrapper'):
results.append(result)
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:
"""Tests for INTERRUPT behavior when plugin prevents default."""
@pytest.mark.asyncio
async def test_event_prevented_interrupts(self):
"""Plugin event prevented should return INTERRUPT."""
wrapper = get_wrapper_module()
entities = get_entities_module()
app = FakeApp()
# Mock session manager to return a valid Session
session = make_session()
app.sess_mgr.get_session = AsyncMock(return_value=session)
# Mock plugin connector - event is prevented
mock_event_ctx = Mock()
mock_event_ctx.is_prevented_default = Mock(return_value=True)
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
stage = wrapper.ResponseWrapper(app)
pipeline_config = make_wrapper_config()
await stage.initialize(pipeline_config)
query = text_query("hello")
query.pipeline_config = pipeline_config
query.resp_message_chain = []
# Create assistant response with content
assistant_resp = Mock()
assistant_resp.role = 'assistant'
assistant_resp.content = "Hello!"
assistant_resp.tool_calls = None
assistant_resp.get_content_platform_message_chain = Mock(
return_value=platform_message.MessageChain([platform_message.Plain(text="Hello!")])
)
query.resp_messages = [assistant_resp]
results = []
async for result in stage.process(query, 'ResponseWrapper'):
results.append(result)
assert len(results) == 1
assert results[0].result_type == entities.ResultType.INTERRUPT
class TestResponseWrapperCustomReply:
"""Tests for custom reply from plugin event."""
@pytest.mark.asyncio
async def test_custom_reply_chain_used(self):
"""Plugin reply_message_chain should replace default."""
wrapper = get_wrapper_module()
entities = get_entities_module()
app = FakeApp()
# Mock session manager to return a valid Session
session = make_session()
app.sess_mgr.get_session = AsyncMock(return_value=session)
# Mock plugin connector with custom reply
custom_chain = platform_message.MessageChain([platform_message.Plain(text="Custom reply")])
mock_event_ctx = Mock()
mock_event_ctx.is_prevented_default = Mock(return_value=False)
mock_event_ctx.event = Mock()
mock_event_ctx.event.reply_message_chain = custom_chain
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
stage = wrapper.ResponseWrapper(app)
pipeline_config = make_wrapper_config()
await stage.initialize(pipeline_config)
query = text_query("hello")
query.pipeline_config = pipeline_config
query.resp_message_chain = []
# Create assistant response
assistant_resp = Mock()
assistant_resp.role = 'assistant'
assistant_resp.content = "Default reply"
assistant_resp.tool_calls = None
assistant_resp.get_content_platform_message_chain = Mock(
return_value=platform_message.MessageChain([platform_message.Plain(text="Default reply")])
)
query.resp_messages = [assistant_resp]
results = []
async for result in stage.process(query, 'ResponseWrapper'):
results.append(result)
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
# Custom chain should be in resp_message_chain
assert len(results[0].new_query.resp_message_chain) == 1
# Should be the custom chain
chain = results[0].new_query.resp_message_chain[0]
assert "Custom reply" in str(chain)
class TestResponseWrapperVariables:
"""Tests for bound plugins variable."""
@pytest.mark.asyncio
async def test_bound_plugins_passed_to_event(self):
"""_pipeline_bound_plugins should be passed to emit_event."""
wrapper = get_wrapper_module()
get_entities_module()
app = FakeApp()
# Mock session manager to return a valid Session
session = make_session()
app.sess_mgr.get_session = AsyncMock(return_value=session)
# Mock plugin connector
mock_event_ctx = Mock()
mock_event_ctx.is_prevented_default = Mock(return_value=False)
mock_event_ctx.event = Mock()
mock_event_ctx.event.reply_message_chain = None
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
stage = wrapper.ResponseWrapper(app)
pipeline_config = make_wrapper_config()
await stage.initialize(pipeline_config)
query = text_query("hello")
query.pipeline_config = pipeline_config
query.resp_message_chain = []
query.variables['_pipeline_bound_plugins'] = ['plugin1', 'plugin2']
# Create assistant response
assistant_resp = Mock()
assistant_resp.role = 'assistant'
assistant_resp.content = "Hello"
assistant_resp.tool_calls = None
assistant_resp.get_content_platform_message_chain = Mock(
return_value=platform_message.MessageChain([platform_message.Plain(text="Hello")])
)
query.resp_messages = [assistant_resp]
results = []
async for result in stage.process(query, 'ResponseWrapper'):
results.append(result)
# 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
@@ -0,0 +1,504 @@
"""Unit tests for plugin connector methods.
Tests cover:
- list_plugins() with filtering and sorting
- list_knowledge_engines() and list_parsers()
- RAG methods (ingest, retrieve, schema)
- Disabled plugin early returns
"""
from __future__ import annotations
import pytest
from unittest.mock import Mock, AsyncMock
from importlib import import_module
def get_connector_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.plugin.connector')
def create_mock_app():
"""Create mock Application for testing."""
mock_app = Mock()
mock_app.logger = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {'plugin': {'enable': True}}
mock_app.persistence_mgr = AsyncMock()
mock_app.persistence_mgr.execute_async = AsyncMock()
return mock_app
def create_mock_connector():
"""Create mock PluginRuntimeConnector instance for testing."""
connector = get_connector_module()
async def mock_disconnect_callback(conn):
pass
return connector.PluginRuntimeConnector(create_mock_app(), mock_disconnect_callback)
class TestListPlugins:
"""Tests for list_plugins method."""
@pytest.mark.asyncio
async def test_returns_empty_when_plugin_disabled(self):
"""Test returns empty list when plugin system disabled."""
connector_module = get_connector_module()
async def mock_disconnect(conn):
pass
mock_app = create_mock_app()
mock_app.instance_config.data = {'plugin': {'enable': False}}
connector = connector_module.PluginRuntimeConnector(mock_app, mock_disconnect)
result = await connector.list_plugins()
assert result == []
@pytest.mark.asyncio
async def test_calls_handler_list_plugins(self):
"""Test that handler.list_plugins is called."""
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
connector.handler.list_plugins = AsyncMock(
return_value=[{'manifest': {'manifest': {'metadata': {'author': 'test', 'name': 'plugin'}}}}]
)
result = await connector.list_plugins()
connector.handler.list_plugins.assert_called_once()
assert result == [{'manifest': {'manifest': {'metadata': {'author': 'test', 'name': 'plugin'}}}}]
@pytest.mark.asyncio
async def test_filters_by_component_kinds(self):
"""Test that plugins are filtered by component kinds."""
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
connector.handler.list_plugins = AsyncMock(
return_value=[
{
'manifest': {'manifest': {'metadata': {'author': 'a', 'name': 'p1'}}},
'components': [
{'manifest': {'manifest': {'kind': 'Command'}}}
],
'debug': False,
},
{
'manifest': {'manifest': {'metadata': {'author': 'b', 'name': 'p2'}}},
'components': [
{'manifest': {'manifest': {'kind': 'Tool'}}}
],
'debug': False,
},
]
)
result = await connector.list_plugins(component_kinds=['Command'])
assert len(result) == 1
assert result[0]['manifest']['manifest']['metadata']['name'] == 'p1'
@pytest.mark.asyncio
async def test_sorts_debug_plugins_first(self):
"""Test that debug plugins are sorted first."""
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
connector.handler.list_plugins = AsyncMock(
return_value=[
{
'manifest': {'manifest': {'metadata': {'author': 'a', 'name': 'normal'}}},
'components': [],
'debug': False,
},
{
'manifest': {'manifest': {'metadata': {'author': 'b', 'name': 'debug'}}},
'components': [],
'debug': True,
},
]
)
connector.ap.persistence_mgr.execute_async = AsyncMock(
return_value=Mock(__iter__=lambda self: iter([]))
)
result = await connector.list_plugins()
# Debug plugin should be first
assert result[0]['debug'] is True
class TestListKnowledgeEngines:
"""Tests for list_knowledge_engines method."""
@pytest.mark.asyncio
async def test_returns_empty_when_plugin_disabled(self):
"""Test returns empty list when plugin system disabled."""
connector_module = get_connector_module()
async def mock_disconnect(conn):
pass
mock_app = create_mock_app()
mock_app.instance_config.data = {'plugin': {'enable': False}}
connector = connector_module.PluginRuntimeConnector(mock_app, mock_disconnect)
result = await connector.list_knowledge_engines()
assert result == []
@pytest.mark.asyncio
async def test_calls_handler_list_knowledge_engines(self):
"""Test that handler method is called."""
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
connector.handler.list_knowledge_engines = AsyncMock(
return_value=[{'plugin_id': 'author/engine', 'name': 'Engine'}]
)
result = await connector.list_knowledge_engines()
connector.handler.list_knowledge_engines.assert_called_once()
assert result == [{'plugin_id': 'author/engine', 'name': 'Engine'}]
class TestListParsers:
"""Tests for list_parsers method."""
@pytest.mark.asyncio
async def test_returns_empty_when_plugin_disabled(self):
"""Test returns empty list when plugin system disabled."""
connector_module = get_connector_module()
async def mock_disconnect(conn):
pass
mock_app = create_mock_app()
mock_app.instance_config.data = {'plugin': {'enable': False}}
connector = connector_module.PluginRuntimeConnector(mock_app, mock_disconnect)
result = await connector.list_parsers()
assert result == []
@pytest.mark.asyncio
async def test_calls_handler_list_parsers(self):
"""Test that handler method is called."""
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
connector.handler.list_parsers = AsyncMock(
return_value=[{'plugin_id': 'author/parser', 'supported_mime_types': ['text/plain']}]
)
result = await connector.list_parsers()
connector.handler.list_parsers.assert_called_once()
assert result == [{'plugin_id': 'author/parser', 'supported_mime_types': ['text/plain']}]
class TestCallParser:
"""Tests for call_parser method."""
@pytest.mark.asyncio
async def test_calls_handler_parse_document(self):
"""Test that handler.parse_document is called with correct args."""
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
connector.handler.parse_document = AsyncMock(return_value={'content': 'parsed'})
result = await connector.call_parser(
'author/parser',
{'mime_type': 'text/plain', 'filename': 'test.txt'},
b'file content',
)
connector.handler.parse_document.assert_called_once_with(
'author', 'parser',
{'mime_type': 'text/plain', 'filename': 'test.txt'},
b'file content',
)
assert result['content'] == 'parsed'
class TestRAGMethods:
"""Tests for RAG-related methods."""
@pytest.mark.asyncio
async def test_call_rag_ingest(self):
"""Test call_rag_ingest calls handler with parsed plugin ID."""
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
connector.handler.rag_ingest_document = AsyncMock(return_value={'status': 'success'})
result = await connector.call_rag_ingest('author/engine', {'file': 'test.pdf'})
connector.handler.rag_ingest_document.assert_called_once_with(
'author', 'engine', {'file': 'test.pdf'}
)
assert result['status'] == 'success'
@pytest.mark.asyncio
async def test_call_rag_retrieve(self):
"""Test call_rag_retrieve calls handler."""
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
connector.handler.retrieve_knowledge = AsyncMock(
return_value={'results': [{'id': 'doc1', 'content': [{'type': 'text', 'text': 'test'}], 'metadata': {}, 'distance': 0.1}]}
)
result = await connector.call_rag_retrieve('author/engine', {'query': 'test'})
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):
"""Test get_rag_creation_schema calls handler."""
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
connector.handler.get_rag_creation_schema = AsyncMock(
return_value={'properties': {'name': {'type': 'string'}}}
)
result = await connector.get_rag_creation_schema('author/engine')
connector.handler.get_rag_creation_schema.assert_called_once_with('author', 'engine')
assert result == {'properties': {'name': {'type': 'string'}}}
@pytest.mark.asyncio
async def test_get_rag_retrieval_schema(self):
"""Test get_rag_retrieval_schema calls handler."""
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
connector.handler.get_rag_retrieval_schema = AsyncMock(
return_value={'properties': {'top_k': {'type': 'integer'}}}
)
result = await connector.get_rag_retrieval_schema('author/engine')
connector.handler.get_rag_retrieval_schema.assert_called_once_with('author', 'engine')
assert result == {'properties': {'top_k': {'type': 'integer'}}}
@pytest.mark.asyncio
async def test_rag_on_kb_create(self):
"""Test rag_on_kb_create calls handler."""
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
connector.handler.rag_on_kb_create = AsyncMock(return_value={'status': 'ok'})
await connector.rag_on_kb_create('author/engine', 'kb-uuid', {'model': 'test'})
connector.handler.rag_on_kb_create.assert_called_once_with(
'author', 'engine', 'kb-uuid', {'model': 'test'}
)
@pytest.mark.asyncio
async def test_rag_on_kb_delete(self):
"""Test rag_on_kb_delete calls handler."""
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
connector.handler.rag_on_kb_delete = AsyncMock(return_value={'status': 'ok'})
await connector.rag_on_kb_delete('author/engine', 'kb-uuid')
connector.handler.rag_on_kb_delete.assert_called_once_with('author', 'engine', 'kb-uuid')
@pytest.mark.asyncio
async def test_call_rag_delete_document(self):
"""Test call_rag_delete_document calls handler."""
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
connector.handler.rag_delete_document = AsyncMock(return_value=True)
result = await connector.call_rag_delete_document('author/engine', 'doc-uuid', 'kb-uuid')
connector.handler.rag_delete_document.assert_called_once_with(
'author', 'engine', 'doc-uuid', 'kb-uuid'
)
assert result is True
class TestRetrieveKnowledge:
"""Tests for retrieve_knowledge method."""
@pytest.mark.asyncio
async def test_returns_empty_results_when_plugin_disabled(self):
"""Test returns empty when plugin disabled."""
connector_module = get_connector_module()
async def mock_disconnect(conn):
pass
mock_app = create_mock_app()
mock_app.instance_config.data = {'plugin': {'enable': False}}
connector = connector_module.PluginRuntimeConnector(mock_app, mock_disconnect)
result = await connector.retrieve_knowledge('author', 'engine', 'retriever', {})
assert result == {'results': []}
class TestDisabledPluginEarlyReturns:
"""Tests for early returns when plugin system is disabled."""
@pytest.mark.asyncio
async def test_list_tools_returns_empty(self):
"""Test list_tools returns empty when disabled."""
connector_module = get_connector_module()
async def mock_disconnect(conn):
pass
mock_app = create_mock_app()
mock_app.instance_config.data = {'plugin': {'enable': False}}
connector = connector_module.PluginRuntimeConnector(mock_app, mock_disconnect)
result = await connector.list_tools()
assert result == []
@pytest.mark.asyncio
async def test_list_commands_returns_empty(self):
"""Test list_commands returns empty when disabled."""
connector_module = get_connector_module()
async def mock_disconnect(conn):
pass
mock_app = create_mock_app()
mock_app.instance_config.data = {'plugin': {'enable': False}}
connector = connector_module.PluginRuntimeConnector(mock_app, mock_disconnect)
result = await connector.list_commands()
assert result == []
@pytest.mark.asyncio
async def test_get_debug_info_returns_empty(self):
"""Test get_debug_info returns empty dict when disabled."""
connector_module = get_connector_module()
async def mock_disconnect(conn):
pass
mock_app = create_mock_app()
mock_app.instance_config.data = {'plugin': {'enable': False}}
connector = connector_module.PluginRuntimeConnector(mock_app, mock_disconnect)
result = await connector.get_debug_info()
assert result == {}
class TestGetPluginInfo:
"""Tests for get_plugin_info method."""
@pytest.mark.asyncio
async def test_calls_handler_get_plugin_info(self):
"""Test that handler.get_plugin_info is called."""
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
connector.handler.get_plugin_info = AsyncMock(
return_value={'manifest': {'metadata': {'name': 'plugin'}}}
)
result = await connector.get_plugin_info('author', 'plugin')
connector.handler.get_plugin_info.assert_called_once_with('author', 'plugin')
assert result == {'manifest': {'metadata': {'name': 'plugin'}}}
class TestSetPluginConfig:
"""Tests for set_plugin_config method."""
@pytest.mark.asyncio
async def test_calls_handler_set_plugin_config(self):
"""Test that handler.set_plugin_config is called."""
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
connector.handler.set_plugin_config = AsyncMock(return_value={'status': 'ok'})
await connector.set_plugin_config('author', 'plugin', {'setting': 'value'})
connector.handler.set_plugin_config.assert_called_once_with(
'author', 'plugin', {'setting': 'value'}
)
class TestPingPluginRuntime:
"""Tests for ping_plugin_runtime method."""
@pytest.mark.asyncio
async def test_raises_when_handler_not_set(self):
"""Test that exception is raised when handler not initialized."""
get_connector_module()
connector = create_mock_connector()
# handler is not set
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)
@pytest.mark.asyncio
async def test_calls_handler_ping(self):
"""Test that handler.ping is called."""
get_connector_module()
connector = create_mock_connector()
connector.handler = AsyncMock()
connector.handler.ping = AsyncMock(return_value={'status': 'ok'})
await connector.ping_plugin_runtime()
connector.handler.ping.assert_called_once()
@@ -0,0 +1,32 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langbot.pkg.plugin.connector import PluginRuntimeConnector, PluginRuntimeNotConnectedError
def make_connector() -> PluginRuntimeConnector:
app = SimpleNamespace(instance_config=SimpleNamespace(data={'plugin': {'enable': True}}))
return PluginRuntimeConnector(app, AsyncMock())
@pytest.mark.asyncio
async def test_ping_plugin_runtime_raises_specific_error_when_not_connected():
connector = make_connector()
with pytest.raises(PluginRuntimeNotConnectedError, match='Plugin runtime is not connected'):
await connector.ping_plugin_runtime()
@pytest.mark.asyncio
async def test_ping_plugin_runtime_delegates_to_connected_handler():
connector = make_connector()
connector.handler = SimpleNamespace(ping=AsyncMock(return_value='pong'))
result = await connector.ping_plugin_runtime()
assert result == 'pong'
connector.handler.ping.assert_awaited_once()
@@ -0,0 +1,143 @@
"""Tests for PluginRuntimeConnector pure logic methods.
Tests methods that don't require real plugin runtime processes:
- _inspect_plugin_package: identity and deps extraction from zip files
- _parse_plugin_id: plugin ID string parsing
"""
from __future__ import annotations
import io
import zipfile
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
class TestExtractDepsMetadata:
"""Tests for dependency metadata extraction from plugin packages."""
def _create_connector(self):
"""Create a connector instance for testing."""
from langbot.pkg.plugin.connector import PluginRuntimeConnector
mock_app = MagicMock()
mock_app.instance_config.data.get.return_value = {'enable': True}
mock_app.logger = MagicMock()
connector = PluginRuntimeConnector(mock_app, MagicMock())
return connector
def test_extract_deps_with_requirements_txt(self):
"""Extract dependency count from requirements.txt in plugin zip."""
connector = self._create_connector()
# Create a mock zip file with requirements.txt
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w') as zf:
zf.writestr('requirements.txt', 'requests>=2.0\nflask\n# comment\n\nnumpy')
zip_bytes = zip_buffer.getvalue()
task_context = SimpleNamespace(metadata={})
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
assert 'requests>=2.0' in task_context.metadata['deps_list']
assert 'flask' in task_context.metadata['deps_list']
assert 'numpy' in task_context.metadata['deps_list']
def test_extract_deps_empty_requirements(self):
"""Handle empty requirements.txt."""
connector = self._create_connector()
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w') as zf:
zf.writestr('requirements.txt', '# only comments\n\n')
zip_bytes = zip_buffer.getvalue()
task_context = SimpleNamespace(metadata={})
connector._inspect_plugin_package(zip_bytes, task_context)
assert task_context.metadata['deps_total'] == 0
assert task_context.metadata['deps_list'] == []
def test_extract_deps_no_requirements_txt(self):
"""Handle zip without requirements.txt."""
connector = self._create_connector()
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w') as zf:
zf.writestr('plugin.py', 'print("hello")')
zip_bytes = zip_buffer.getvalue()
task_context = SimpleNamespace(metadata={})
connector._inspect_plugin_package(zip_bytes, task_context)
# No requirements.txt found, metadata unchanged
assert 'deps_total' not in task_context.metadata
def test_extract_deps_none_task_context(self):
"""Handle None task_context gracefully."""
connector = self._create_connector()
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w') as zf:
zf.writestr('requirements.txt', 'requests')
zip_bytes = zip_buffer.getvalue()
# Should return early without error
connector._inspect_plugin_package(zip_bytes, None)
def test_extract_deps_invalid_zip(self):
"""Handle invalid zip file gracefully."""
connector = self._create_connector()
# Not a valid zip
invalid_bytes = b'not a zip file'
task_context = SimpleNamespace(metadata={})
connector._inspect_plugin_package(invalid_bytes, task_context)
# Should catch exception and pass silently
assert 'deps_total' not in task_context.metadata
def test_extract_deps_nested_requirements(self):
"""Handle requirements.txt in nested directory."""
connector = self._create_connector()
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w') as zf:
zf.writestr('subdir/requirements.txt', 'pytest\nblack')
zip_bytes = zip_buffer.getvalue()
task_context = SimpleNamespace(metadata={})
connector._inspect_plugin_package(zip_bytes, task_context)
# Should find requirements.txt in subdirectory
assert task_context.metadata['deps_total'] == 2
class TestParsePluginId:
"""Tests for _parse_plugin_id static method."""
def test_parse_valid_plugin_id(self):
"""Parse valid plugin ID format 'author/name'."""
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_empty(self):
"""Empty plugin ID is invalid."""
from langbot.pkg.plugin.connector import PluginRuntimeConnector
with pytest.raises(ValueError):
PluginRuntimeConnector._parse_plugin_id('')
@@ -0,0 +1,54 @@
"""Unit tests for plugin connector static methods.
Tests cover:
- _parse_plugin_id() parsing and validation
"""
from __future__ import annotations
import pytest
from importlib import import_module
def get_connector_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.plugin.connector')
class TestParsePluginId:
"""Tests for _parse_plugin_id static method."""
def test_valid_plugin_id_simple(self):
"""Test parsing valid plugin ID with simple format."""
connector = get_connector_module()
author, name = connector.PluginRuntimeConnector._parse_plugin_id('langbot/rag-engine')
assert author == 'langbot'
assert name == 'rag-engine'
def test_invalid_plugin_id_no_slash(self):
"""Test that ValueError is raised when no slash present."""
connector = get_connector_module()
with pytest.raises(ValueError) as exc_info:
connector.PluginRuntimeConnector._parse_plugin_id('invalid-plugin-id')
assert 'Invalid plugin_id format' in str(exc_info.value)
assert 'invalid-plugin-id' in str(exc_info.value)
def test_invalid_plugin_id_empty_string(self):
"""Test that ValueError is raised for empty string."""
connector = get_connector_module()
with pytest.raises(ValueError) as exc_info:
connector.PluginRuntimeConnector._parse_plugin_id('')
assert 'Invalid plugin_id format' in str(exc_info.value)
def test_valid_plugin_id_single_character_parts(self):
"""Test parsing plugin ID with single character author and name."""
connector = get_connector_module()
author, name = connector.PluginRuntimeConnector._parse_plugin_id('a/b')
assert author == 'a'
assert name == 'b'
def test_valid_plugin_id_with_hyphens_and_underscores(self):
"""Test parsing plugin ID with hyphens and underscores."""
connector = get_connector_module()
author, name = connector.PluginRuntimeConnector._parse_plugin_id('lang-bot/my_rag_engine')
assert author == 'lang-bot'
assert name == 'my_rag_engine'
@@ -0,0 +1,210 @@
"""Unit tests for plugin connector _inspect_plugin_package method.
Tests cover:
- Extracting requirements.txt from ZIP
- Parsing dependency lines
- Handling missing requirements.txt
- Handling empty/malformed requirements.txt
"""
from __future__ import annotations
import zipfile
import io
from unittest.mock import Mock
from importlib import import_module
def get_connector_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.plugin.connector')
def create_mock_connector():
"""Create a mock PluginRuntimeConnector instance for testing."""
connector = get_connector_module()
mock_app = Mock()
mock_app.logger = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {'plugin': {'enable': True}}
# Mock disconnect callback
async def mock_disconnect_callback(connector):
pass
return connector.PluginRuntimeConnector(mock_app, mock_disconnect_callback)
def create_zip_with_requirements(requirements_content: str) -> bytes:
"""Create a ZIP file containing requirements.txt with given content."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr('requirements.txt', requirements_content)
return buf.getvalue()
def create_zip_with_nested_requirements(requirements_content: str) -> bytes:
"""Create a ZIP file with requirements.txt in nested directory."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr('plugin/requirements.txt', requirements_content)
return buf.getvalue()
def create_zip_without_requirements() -> bytes:
"""Create a ZIP file without requirements.txt."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr('main.py', 'print("hello")')
zf.writestr('manifest.yaml', 'name: test')
return buf.getvalue()
class TestExtractDepsMetadata:
"""Tests for dependency metadata extraction from plugin packages."""
def test_extract_simple_requirements(self):
"""Test extracting simple requirements.txt."""
connector_instance = create_mock_connector()
# Create test ZIP
zip_bytes = create_zip_with_requirements('requests>=2.0\nflask==1.0\nnumpy')
# Create task context
task_context = Mock()
task_context.metadata = {}
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']
def test_extract_requirements_with_comments_and_empty_lines(self):
"""Test that comments and empty lines are filtered."""
connector_instance = create_mock_connector()
requirements = '''# This is a comment
requests>=2.0
# Another comment
flask==1.0
numpy'''
zip_bytes = create_zip_with_requirements(requirements)
task_context = Mock()
task_context.metadata = {}
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', [])
def test_extract_nested_requirements(self):
"""Test extracting requirements.txt from nested directory."""
connector_instance = create_mock_connector()
zip_bytes = create_zip_with_nested_requirements('requests\nflask')
task_context = Mock()
task_context.metadata = {}
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
def test_no_requirements_in_zip(self):
"""Test handling ZIP without requirements.txt."""
connector_instance = create_mock_connector()
zip_bytes = create_zip_without_requirements()
task_context = Mock()
task_context.metadata = {}
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
assert task_context.metadata.get('deps_list') is None
def test_empty_requirements_file(self):
"""Test handling empty requirements.txt."""
connector_instance = create_mock_connector()
zip_bytes = create_zip_with_requirements('')
task_context = Mock()
task_context.metadata = {}
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
assert task_context.metadata.get('deps_list') == []
def test_requirements_only_comments(self):
"""Test handling requirements.txt with only comments."""
connector_instance = create_mock_connector()
requirements = '''# Comment 1
# Comment 2
# Comment 3'''
zip_bytes = create_zip_with_requirements(requirements)
task_context = Mock()
task_context.metadata = {}
connector_instance._inspect_plugin_package(zip_bytes, task_context)
assert task_context.metadata.get('deps_total') == 0
assert task_context.metadata.get('deps_list') == []
def test_task_context_none_returns_early(self):
"""Test that method returns early when task_context is None."""
connector_instance = create_mock_connector()
zip_bytes = create_zip_with_requirements('requests')
# Should return without error when task_context is None
connector_instance._inspect_plugin_package(zip_bytes, None)
# No exception should be raised
def test_malformed_zip_handling(self):
"""Test handling malformed ZIP bytes."""
connector_instance = create_mock_connector()
# Invalid ZIP bytes
invalid_bytes = b'not a valid zip file'
task_context = Mock()
task_context.metadata = {}
# Should silently handle exception (pass in try/except)
connector_instance._inspect_plugin_package(invalid_bytes, task_context)
# metadata should remain unchanged
assert task_context.metadata == {}
def test_requirements_with_unicode_decode_error(self):
"""Test handling requirements.txt with non-UTF8 content."""
connector_instance = create_mock_connector()
# Create ZIP with non-UTF8 content in requirements.txt
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
# Write bytes that will cause decode issues
# \x80 is invalid UTF-8, but errors='ignore' will skip it
zf.writestr('requirements.txt', b'requests\nflask\n\x80invalid')
zip_bytes = buf.getvalue()
task_context = Mock()
task_context.metadata = {}
# errors='ignore' will decode \x80invalid as 'invalid' (skipping \x80)
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', [])
+181
View File
@@ -0,0 +1,181 @@
"""Tests for RuntimeConnectionHandler helper functions.
Tests handler helper methods that don't require full handler setup.
"""
from __future__ import annotations
from types import SimpleNamespace
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."""
@pytest.fixture
def mock_app(self):
"""Create mock app with query pool."""
app = SimpleNamespace()
app.query_pool = SimpleNamespace()
app.query_pool.cached_queries = {}
app.logger = SimpleNamespace()
app.logger.debug = MagicMock()
return app
@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."""
runtime_handler = make_handler(mock_app)
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
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
response = await runtime_handler.actions[PluginToRuntimeAction.GET_QUERY_VAR.value]({
'query_id': 'test-query',
'key': 'existing_var',
})
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 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
response = await runtime_handler.actions[PluginToRuntimeAction.GET_QUERY_VARS.value]({
'query_id': 'test-query',
})
assert response.code == 0
assert response.data == {'vars': mock_query.variables}
class TestHandlerRagErrorResponse:
"""Tests for _make_rag_error_response helper."""
def test_make_rag_error_response_basic(self):
"""Test basic error response creation."""
from langbot.pkg.plugin.handler import _make_rag_error_response
error = Exception("test error")
response = _make_rag_error_response(error, 'TestError')
# ActionResponse is a pydantic model, check message field
assert 'TestError' in response.message
assert 'test error' in response.message
assert 'Exception' in response.message
def test_make_rag_error_response_with_context(self):
"""Test error response with extra context."""
from langbot.pkg.plugin.handler import _make_rag_error_response
error = ValueError("invalid input")
response = _make_rag_error_response(
error,
'ValidationError',
field='name',
value='test'
)
assert 'ValidationError' in response.message
assert 'field=name' in response.message
assert 'value=test' in response.message
assert 'ValueError' in response.message
def test_make_rag_error_response_exception_type(self):
"""Test error response includes exception type."""
from langbot.pkg.plugin.handler import _make_rag_error_response
error = RuntimeError("connection failed")
response = _make_rag_error_response(error, 'ConnectionError')
assert 'RuntimeError' in response.message
assert 'ConnectionError' in response.message
assert 'connection failed' in response.message
def test_make_rag_error_response_empty_context(self):
"""Test error response with no extra context."""
from langbot.pkg.plugin.handler import _make_rag_error_response
error = KeyError("missing_key")
response = _make_rag_error_response(error, 'LookupError')
# No context parts means no brackets
assert '[' in response.message # Still has error type bracket
assert 'KeyError' in response.message
class TestConstantsSemanticVersion:
"""Tests for version constant access."""
def test_semantic_version_exists(self):
"""Test semantic_version is defined."""
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 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 langbot.pkg.utils import constants
assert hasattr(constants, 'required_database_version')
assert isinstance(constants.required_database_version, int)
@@ -0,0 +1,351 @@
"""Unit tests for RuntimeConnectionHandler action handlers."""
from __future__ import annotations
import base64
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction, RuntimeToLangBotAction
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 make_result(first_item=None):
result = Mock()
result.first = Mock(return_value=first_item)
return result
def compiled_params(statement):
return statement.compile().params
class TestInitializePluginSettings:
"""Tests for initialize_plugin_settings action handler."""
@pytest.fixture
def app(self):
mock_app = Mock()
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_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(),
]
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': {},
}
@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(),
]
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'},
})
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."""
@pytest.fixture
def app(self):
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {
'plugin': {
'binary_storage': {
'max_value_bytes': 1024,
},
},
}
mock_app.persistence_mgr = Mock()
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=make_result())
mock_app.logger = Mock()
return mock_app
@staticmethod
def payload(value: bytes):
return {
'key': 'test-key',
'owner_type': 'plugin',
'owner': 'test-owner',
'value_base64': base64.b64encode(value).decode('utf-8'),
}
@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)
)
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_and_inserts_storage(self, app):
"""A new small value is inserted into binary storage."""
runtime_handler = make_handler(app)
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](
self.payload(b'x' * 512)
)
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_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'))
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](
self.payload(b'new')
)
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_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 response.code != 0
assert '10485761 > 10485760 bytes' in response.message
app.persistence_mgr.execute_async.assert_not_awaited()
@pytest.mark.asyncio
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
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](
self.payload(b'x' * 2048)
)
assert response.code == 0
assert app.persistence_mgr.execute_async.await_count == 2
@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 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, 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()
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value]({
'plugin_author': 'test-author',
'plugin_name': 'test-plugin',
})
assert response.code == 0
assert response.data == {
'enabled': True,
'priority': 0,
'plugin_config': {},
'install_source': 'local',
'install_info': {},
}
@pytest.mark.asyncio
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)
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value]({
'plugin_author': 'test-author',
'plugin_name': 'test-plugin',
})
assert response.code == 0
assert response.data == {
'enabled': False,
'priority': 10,
'plugin_config': {'custom': 'config'},
'install_source': 'github',
'install_info': {'repo': 'test/repo'},
}
class TestGetBinaryStorage:
"""Tests for get_binary_storage action handler."""
@pytest.fixture
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, 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'))
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value]({
'key': 'test-key',
'owner_type': 'plugin',
'owner': 'test-owner',
})
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, app):
"""Missing binary storage rows return an error response."""
runtime_handler = make_handler(app)
app.persistence_mgr.execute_async.return_value = make_result()
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value]({
'key': 'test-key',
'owner_type': 'plugin',
'owner': 'test-owner',
})
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 app(self):
mock_app = Mock()
mock_app.query_pool = Mock()
mock_app.query_pool.cached_queries = {}
mock_app.logger = Mock()
return mock_app
@pytest.mark.asyncio
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)
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, 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
response = await runtime_handler.actions[PluginToRuntimeAction.GET_BOT_UUID.value]({
'query_id': 'existing-query',
})
assert response.code == 0
assert response.data == {'bot_uuid': 'test-bot-uuid'}
@@ -0,0 +1,127 @@
"""Unit tests for plugin handler helper functions and methods.
Tests cover:
- _make_rag_error_response() helper function
- RuntimeConnectionHandler cleanup_plugin_data method
"""
from __future__ import annotations
import pytest
from unittest.mock import Mock, AsyncMock
from importlib import import_module
def get_handler_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.plugin.handler')
class TestMakeRagErrorResponse:
"""Tests for _make_rag_error_response helper function."""
def test_creates_error_response_with_exception(self):
"""Test basic error response creation."""
handler = get_handler_module()
error = ValueError("test error message")
result = handler._make_rag_error_response(error, 'TestError')
# ActionResponse.error() returns code=1 (error status)
assert result.code == 1
assert 'TestError' in result.message
assert 'ValueError' in result.message
assert 'test error message' in result.message
def test_includes_error_type_in_message(self):
"""Test that error type is included in message."""
handler = get_handler_module()
error = RuntimeError("something went wrong")
result = handler._make_rag_error_response(error, 'VectorStoreError')
assert '[VectorStoreError/RuntimeError]' in result.message
def test_includes_extra_context_in_message(self):
"""Test that extra context fields are included."""
handler = get_handler_module()
error = Exception("embedding failed")
result = handler._make_rag_error_response(
error,
'EmbeddingError',
embedding_model_uuid='test-uuid-123',
collection_id='collection-456',
)
assert 'embedding_model_uuid=test-uuid-123' in result.message
assert 'collection_id=collection-456' in result.message
def test_handles_exception_with_no_message(self):
"""Test handling exception with empty message."""
handler = get_handler_module()
error = Exception()
result = handler._make_rag_error_response(error, 'GenericError')
# ActionResponse.error() returns code=1 (error status)
assert result.code == 1
assert '[GenericError/Exception]' in result.message
def test_formats_context_with_multiple_fields(self):
"""Test multiple context fields are comma separated."""
handler = get_handler_module()
error = IOError("file not found")
result = handler._make_rag_error_response(
error,
'FileServiceError',
storage_path='/data/file.pdf',
kb_id='kb-001',
)
assert '[storage_path=/data/file.pdf, kb_id=kb-001]' in result.message
class TestCleanupPluginData:
"""Tests for cleanup_plugin_data method."""
@pytest.mark.asyncio
async def test_deletes_plugin_settings(self):
"""Test that plugin settings are deleted."""
handler_module = get_handler_module()
mock_app = Mock()
mock_app.persistence_mgr = AsyncMock()
mock_app.persistence_mgr.execute_async = AsyncMock()
# Mock the handler without connection (we only need ap)
handler_instance = Mock(spec=handler_module.RuntimeConnectionHandler)
handler_instance.ap = mock_app
# Call cleanup_plugin_data
await handler_module.RuntimeConnectionHandler.cleanup_plugin_data(
handler_instance, 'test-author', 'test-plugin'
)
# Verify plugin settings delete was called
calls = mock_app.persistence_mgr.execute_async.call_args_list
assert len(calls) >= 1
@pytest.mark.asyncio
async def test_deletes_binary_storage(self):
"""Test that binary storage is deleted."""
handler_module = get_handler_module()
mock_app = Mock()
mock_app.persistence_mgr = AsyncMock()
mock_app.persistence_mgr.execute_async = AsyncMock()
handler_instance = Mock(spec=handler_module.RuntimeConnectionHandler)
handler_instance.ap = mock_app
await handler_module.RuntimeConnectionHandler.cleanup_plugin_data(
handler_instance, 'author', 'plugin-name'
)
# Should have at least 2 calls: one for settings, one for binary storage
assert mock_app.persistence_mgr.execute_async.call_count >= 2
@@ -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()
@@ -0,0 +1,25 @@
"""Test plugin ID parsing validation."""
import pytest
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
def test_parse_plugin_id_accepts_author_name():
assert PluginRuntimeConnector._parse_plugin_id('langbot/rag-engine') == ('langbot', 'rag-engine')
@pytest.mark.parametrize(
'plugin_id',
[
'',
'author',
'author/',
'/name',
'author/name/extra',
'/',
],
)
def test_parse_plugin_id_rejects_malformed_ids(plugin_id):
with pytest.raises(ValueError, match='Expected'):
PluginRuntimeConnector._parse_plugin_id(plugin_id)
@@ -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()
+295
View File
@@ -0,0 +1,295 @@
"""
Test fixtures for provider/modelmgr tests.
Provides fake persistence, mock requester registry, and test utilities
without calling real LLM APIs or network requests.
"""
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock
from types import SimpleNamespace
from langbot.pkg.provider.modelmgr import requester
from langbot.pkg.provider.modelmgr import token
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
from langbot.pkg.entity.persistence import model as persistence_model
from langbot.pkg.discover import engine as discover_engine
class FakeProviderAPIRequester(requester.ProviderAPIRequester):
"""Fake requester for testing that does not make real API calls."""
name = 'fake-requester'
default_config = {'base_url': 'https://fake-api.example.com', 'timeout': 30}
def __init__(self, ap, config: dict):
super().__init__(ap, config)
self._invoke_count = 0
self._last_messages = None
self._last_model = None
async def invoke_llm(
self,
query,
model: requester.RuntimeLLMModel,
messages: list,
funcs=None,
extra_args={},
remove_think=False,
):
"""Return a fake message response."""
self._invoke_count += 1
self._last_messages = messages
self._last_model = model
# Import the message entity for response
import langbot_plugin.api.entities.builtin.provider.message as provider_message
return provider_message.Message(
role='assistant',
content=[provider_message.ContentElement(type='text', text='Fake LLM response')],
)
async def invoke_llm_stream(
self,
query,
model: requester.RuntimeLLMModel,
messages: list,
funcs=None,
extra_args={},
remove_think=False,
):
"""Yield fake message chunks."""
import langbot_plugin.api.entities.builtin.provider.message as provider_message
yield provider_message.MessageChunk(
role='assistant',
content=[provider_message.ContentElement(type='text', text='Fake stream chunk')],
)
async def invoke_embedding(self, model, input_text: list, extra_args={}):
"""Return fake embedding vectors."""
return [[0.1, 0.2, 0.3] for _ in input_text]
async def invoke_rerank(self, model, query: str, documents: list, extra_args={}):
"""Return fake rerank results."""
return [{'index': i, 'relevance_score': 0.9 - i * 0.1} for i in range(len(documents))]
class AnotherFakeRequester(requester.ProviderAPIRequester):
"""Another fake requester for multi-requester tests."""
name = 'another-fake-requester'
default_config = {'base_url': 'https://another-fake.example.com'}
async def invoke_llm(self, query, model, messages, funcs=None, extra_args={}, remove_think=False):
import langbot_plugin.api.entities.builtin.provider.message as provider_message
return provider_message.Message(role='assistant', content=[provider_message.ContentElement(type='text', text='Another response')])
async def invoke_rerank(self, model, query: str, documents: list, extra_args={}):
"""Return fake rerank results."""
return [{'index': i, 'relevance_score': 0.9 - i * 0.1} for i in range(len(documents))]
def _create_fake_component(name: str, requester_class: type) -> Mock:
"""Create a fake Component mock for a requester."""
# Use Mock to allow overriding get_python_component_class
component = Mock(spec=discover_engine.Component)
component.metadata = Mock()
component.metadata.name = name
component.get_python_component_class = Mock(return_value=requester_class)
return component
def _make_mock_result(items: list = None, first_item=None):
"""Create a mock result object for persistence queries."""
result = Mock()
result.all = Mock(return_value=items or [])
result.first = Mock(return_value=first_item)
return result
def _make_row_mock(entity):
"""Create a mock Row-like object that can be unpacked via _mapping.
Note: This function returns the actual entity directly since Mock objects
don't pass isinstance(provider_info, sqlalchemy.Row) checks. The code
in modelmgr.load_provider handles this via the else branch.
"""
return entity
@pytest.fixture
def mock_app_for_modelmgr():
"""Provides a mock Application for ModelManager tests."""
app = SimpleNamespace()
app.logger = Mock()
app.logger.debug = Mock()
app.logger.info = Mock()
app.logger.warning = Mock()
app.logger.error = Mock()
# Fake persistence manager - returns empty results by default
app.persistence_mgr = SimpleNamespace()
async def default_execute(query):
return _make_mock_result([])
app.persistence_mgr.execute_async = AsyncMock(side_effect=default_execute)
# Fake discover engine
app.discover = SimpleNamespace()
app.discover.get_components_by_kind = Mock(return_value=[])
# Fake instance config
app.instance_config = SimpleNamespace()
app.instance_config.data = {'space': {'disable_models_service': True}}
# Other services (not used in basic tests)
app.space_service = AsyncMock()
app.llm_model_service = AsyncMock()
app.embedding_models_service = AsyncMock()
app.monitoring_service = AsyncMock()
return app
@pytest.fixture
def fake_requester_registry(mock_app_for_modelmgr):
"""Provides a ModelManager with fake requester registry."""
app = mock_app_for_modelmgr
# Create fake components
fake_component = _create_fake_component('fake-requester', FakeProviderAPIRequester)
another_component = _create_fake_component('another-fake-requester', AnotherFakeRequester)
app.discover.get_components_by_kind = Mock(
return_value=[fake_component, another_component]
)
model_mgr = ModelManager(app)
return model_mgr
@pytest.fixture
def fake_persistence_data():
"""Provides fake persistence data for models and providers."""
provider_uuid = 'test-provider-uuid'
provider_uuid2 = 'test-provider-uuid-2'
providers = [
persistence_model.ModelProvider(
uuid=provider_uuid,
name='Test Provider',
requester='fake-requester',
base_url='https://test.example.com',
api_keys=['test-api-key-1', 'test-api-key-2'],
),
persistence_model.ModelProvider(
uuid=provider_uuid2,
name='Test Provider 2',
requester='another-fake-requester',
base_url='https://test2.example.com',
api_keys=['key-3'],
),
]
llm_models = [
persistence_model.LLMModel(
uuid='test-llm-uuid-1',
name='TestLLM-1',
provider_uuid=provider_uuid,
abilities=['func_call'],
extra_args={'temperature': 0.7},
),
persistence_model.LLMModel(
uuid='test-llm-uuid-2',
name='TestLLM-2',
provider_uuid=provider_uuid,
abilities=['vision'],
extra_args={},
),
]
embedding_models = [
persistence_model.EmbeddingModel(
uuid='test-embedding-uuid-1',
name='TestEmbedding-1',
provider_uuid=provider_uuid,
extra_args={'dimensions': 768},
),
]
rerank_models = [
persistence_model.RerankModel(
uuid='test-rerank-uuid-1',
name='TestRerank-1',
provider_uuid=provider_uuid2,
extra_args={},
),
]
return {
'providers': providers,
'llm_models': llm_models,
'embedding_models': embedding_models,
'rerank_models': rerank_models,
'provider_uuid': provider_uuid,
'provider_uuid2': provider_uuid2,
}
@pytest.fixture
def runtime_provider(fake_persistence_data, mock_app_for_modelmgr):
"""Provides a RuntimeProvider instance for testing."""
provider_entity = fake_persistence_data['providers'][0]
token_mgr = token.TokenManager(name=provider_entity.uuid, tokens=provider_entity.api_keys or [])
requester_inst = FakeProviderAPIRequester(mock_app_for_modelmgr, {'base_url': provider_entity.base_url})
return requester.RuntimeProvider(
provider_entity=provider_entity,
token_mgr=token_mgr,
requester=requester_inst,
)
@pytest.fixture
def runtime_llm_model(fake_persistence_data, runtime_provider):
"""Provides a RuntimeLLMModel instance for testing."""
model_entity = fake_persistence_data['llm_models'][0]
return requester.RuntimeLLMModel(
model_entity=model_entity,
provider=runtime_provider,
)
@pytest.fixture
def runtime_embedding_model(fake_persistence_data, runtime_provider):
"""Provides a RuntimeEmbeddingModel instance for testing."""
model_entity = fake_persistence_data['embedding_models'][0]
return requester.RuntimeEmbeddingModel(
model_entity=model_entity,
provider=runtime_provider,
)
@pytest.fixture
def runtime_rerank_model(fake_persistence_data, mock_app_for_modelmgr):
"""Provides a RuntimeRerankModel instance for testing."""
provider_entity = fake_persistence_data['providers'][1]
token_mgr = token.TokenManager(name=provider_entity.uuid, tokens=provider_entity.api_keys or [])
requester_inst = AnotherFakeRequester(mock_app_for_modelmgr, {'base_url': provider_entity.base_url})
provider = requester.RuntimeProvider(
provider_entity=provider_entity,
token_mgr=token_mgr,
requester=requester_inst,
)
model_entity = fake_persistence_data['rerank_models'][0]
return requester.RuntimeRerankModel(
model_entity=model_entity,
provider=provider,
)
@@ -0,0 +1,32 @@
"""Tests for AnthropicMessages requester.
Tests config and pure utility methods.
"""
from __future__ import annotations
from unittest.mock import MagicMock
class TestAnthropicMessagesConfig:
"""Tests for default config."""
def test_default_config_values(self):
"""Check default_config."""
from langbot.pkg.provider.modelmgr.requesters.anthropicmsgs import AnthropicMessages
assert AnthropicMessages.default_config['base_url'] == 'https://api.anthropic.com'
assert AnthropicMessages.default_config['timeout'] == 120
def test_config_override(self):
"""Config can override defaults."""
from langbot.pkg.provider.modelmgr.requesters.anthropicmsgs import AnthropicMessages
mock_app = MagicMock()
req = AnthropicMessages(mock_app, {
'base_url': 'https://custom.anthropic.com',
'timeout': 60,
})
assert req.requester_cfg['base_url'] == 'https://custom.anthropic.com'
assert req.requester_cfg['timeout'] == 60
@@ -0,0 +1,247 @@
"""Tests for requester error handling - direct import version.
Tests error handling branches by importing real packages and mocking
only the necessary dependencies.
"""
from __future__ import annotations
import asyncio
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."""
@pytest.fixture
def mock_app(self):
"""Create mock Application."""
app = MagicMock()
app.tool_mgr = MagicMock()
app.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=[])
return app
@pytest.fixture
def mock_model(self):
"""Create mock RuntimeLLMModel."""
model = MagicMock()
model.model_entity = MagicMock()
model.model_entity.name = 'gpt-4'
model.provider = MagicMock()
model.provider.token_mgr = MagicMock()
model.provider.token_mgr.get_token = MagicMock(return_value='test-key')
return model
@pytest.fixture
def mock_message(self):
"""Create mock provider message."""
msg = MagicMock()
msg.dict = MagicMock(return_value={'role': 'user', 'content': 'test'})
return msg
@pytest.fixture
def requester_with_mocked_client(self, mock_app):
"""Create requester with mocked OpenAI client."""
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
req = OpenAIChatCompletions(mock_app, {
'base_url': 'https://api.openai.com/v1',
'timeout': 120,
})
# Replace client with mock
req.client = MagicMock()
req.client.chat = MagicMock()
req.client.chat.completions = MagicMock()
req.client.chat.completions.create = AsyncMock()
return req
@pytest.mark.asyncio
async def test_timeout_error(self, requester_with_mocked_client, mock_model, mock_message):
"""TimeoutError is wrapped as RequesterError."""
requester_with_mocked_client.client.chat.completions.create = AsyncMock(
side_effect=asyncio.TimeoutError()
)
with pytest.raises(RequesterError) as exc:
await requester_with_mocked_client.invoke_llm(
query=None,
model=mock_model,
messages=[mock_message],
)
assert '超时' in str(exc.value)
@pytest.mark.asyncio
async def test_bad_request_context_length(self, requester_with_mocked_client, mock_model, mock_message):
"""BadRequestError with context_length_exceeded has special message."""
error = openai.BadRequestError(
message='context_length_exceeded: max 4096',
response=MagicMock(status_code=400),
body={}
)
requester_with_mocked_client.client.chat.completions.create = AsyncMock(
side_effect=error
)
with pytest.raises(RequesterError) as exc:
await requester_with_mocked_client.invoke_llm(
query=None,
model=mock_model,
messages=[mock_message],
)
assert '上文过长' in str(exc.value)
@pytest.mark.asyncio
async def test_authentication_error(self, requester_with_mocked_client, mock_model, mock_message):
"""AuthenticationError shows invalid api-key message."""
error = openai.AuthenticationError(
message='Invalid API key',
response=MagicMock(status_code=401),
body={}
)
requester_with_mocked_client.client.chat.completions.create = AsyncMock(
side_effect=error
)
with pytest.raises(RequesterError) as exc:
await requester_with_mocked_client.invoke_llm(
query=None,
model=mock_model,
messages=[mock_message],
)
assert 'api-key' in str(exc.value).lower() or '无效' in str(exc.value)
@pytest.mark.asyncio
async def test_rate_limit_error(self, requester_with_mocked_client, mock_model, mock_message):
"""RateLimitError shows rate limit message."""
error = openai.RateLimitError(
message='Rate limit exceeded',
response=MagicMock(status_code=429),
body={}
)
requester_with_mocked_client.client.chat.completions.create = AsyncMock(
side_effect=error
)
with pytest.raises(RequesterError) as exc:
await requester_with_mocked_client.invoke_llm(
query=None,
model=mock_model,
messages=[mock_message],
)
assert '频繁' in str(exc.value) or '余额' in str(exc.value)
class TestInvokeEmbeddingErrorHandling:
"""Tests for invoke_embedding error handling."""
@pytest.fixture
def mock_app(self):
return MagicMock()
@pytest.fixture
def mock_embedding_model(self):
model = MagicMock()
model.model_entity = MagicMock()
model.model_entity.name = 'text-embedding-ada-002'
model.model_entity.extra_args = {}
model.provider = MagicMock()
model.provider.token_mgr = MagicMock()
model.provider.token_mgr.get_token = MagicMock(return_value='test-key')
return model
@pytest.fixture
def requester_with_mocked_client(self, mock_app):
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
req = OpenAIChatCompletions(mock_app, {})
req.client = MagicMock()
req.client.embeddings = MagicMock()
req.client.embeddings.create = AsyncMock()
return req
@pytest.mark.asyncio
async def test_embedding_timeout_error(self, requester_with_mocked_client, mock_embedding_model):
"""TimeoutError in embedding request."""
requester_with_mocked_client.client.embeddings.create = AsyncMock(
side_effect=asyncio.TimeoutError()
)
with pytest.raises(RequesterError) as exc:
await requester_with_mocked_client.invoke_embedding(
model=mock_embedding_model,
input_text=['test'],
)
assert '超时' in str(exc.value)
@pytest.mark.asyncio
async def test_embedding_bad_request_error(self, requester_with_mocked_client, mock_embedding_model):
"""BadRequestError in embedding request."""
error = openai.BadRequestError(
message='Invalid model',
response=MagicMock(status_code=400),
body={}
)
requester_with_mocked_client.client.embeddings.create = AsyncMock(
side_effect=error
)
with pytest.raises(RequesterError) as exc:
await requester_with_mocked_client.invoke_embedding(
model=mock_embedding_model,
input_text=['test'],
)
assert '参数' in str(exc.value)
class TestRequesterErrorClass:
"""Tests for RequesterError."""
def test_error_message_prefix(self):
"""RequesterError has '模型请求失败' prefix."""
from langbot.pkg.provider.modelmgr.errors import RequesterError
error = RequesterError('test error')
assert '模型请求失败' in str(error)
def test_error_is_exception(self):
"""RequesterError inherits Exception."""
from langbot.pkg.provider.modelmgr.errors import RequesterError
error = RequesterError('test')
assert isinstance(error, Exception)
class TestDefaultConfig:
"""Tests for requester default config."""
def test_default_config(self):
"""Check default_config values."""
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
assert OpenAIChatCompletions.default_config['base_url'] == 'https://api.openai.com/v1'
assert OpenAIChatCompletions.default_config['timeout'] == 120
def test_config_override(self):
"""Config overrides defaults."""
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
req = OpenAIChatCompletions(MagicMock(), {
'base_url': 'https://custom.com/v1',
'timeout': 60,
})
assert req.requester_cfg['base_url'] == 'https://custom.com/v1'
assert req.requester_cfg['timeout'] == 60
@@ -0,0 +1,340 @@
"""Tests for requester pure utility functions.
Tests the helper methods in OpenAIChatCompletions that don't require network calls.
"""
from __future__ import annotations
from unittest.mock import MagicMock
from tests.utils.import_isolation import isolated_sys_modules
class TestMaskApiKey:
"""Tests for _mask_api_key method."""
def _create_requester_with_mocks(self):
"""Create requester instance with mocked dependencies."""
mocks = {
'langbot.pkg.core.app': MagicMock(),
'langbot_plugin.api.entities.builtin.resource.tool': MagicMock(),
'langbot_plugin.api.entities.builtin.pipeline.query': MagicMock(),
'langbot_plugin.api.entities.builtin.provider.message': MagicMock(),
'langbot.pkg.provider.modelmgr.errors': MagicMock(),
}
with isolated_sys_modules(mocks):
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
mock_app = MagicMock()
requester = OpenAIChatCompletions(mock_app, {})
return requester
def test_mask_api_key_full(self):
"""Mask a full API key."""
requester = self._create_requester_with_mocks()
result = requester._mask_api_key('sk-1234567890abcdef')
assert result == 'sk-1...cdef'
def test_mask_api_key_short(self):
"""Mask a short API key (<=8 chars)."""
requester = self._create_requester_with_mocks()
result = requester._mask_api_key('short')
assert result == '****'
def test_mask_api_key_empty(self):
"""Empty API key returns empty string."""
requester = self._create_requester_with_mocks()
result = requester._mask_api_key('')
assert result == ''
def test_mask_api_key_none(self):
"""None API key returns empty string."""
requester = self._create_requester_with_mocks()
result = requester._mask_api_key(None)
assert result == ''
def test_mask_api_key_exact_8_chars(self):
"""API key with exactly 8 chars is masked as **** (<=8 threshold)."""
requester = self._create_requester_with_mocks()
result = requester._mask_api_key('12345678')
assert result == '****' # <= 8 chars gets masked
class TestInferModelType:
"""Tests for _infer_model_type method."""
def _create_requester_with_mocks(self):
mocks = {
'langbot.pkg.core.app': MagicMock(),
'langbot_plugin.api.entities.builtin.resource.tool': MagicMock(),
'langbot_plugin.api.entities.builtin.pipeline.query': MagicMock(),
'langbot_plugin.api.entities.builtin.provider.message': MagicMock(),
'langbot.pkg.provider.modelmgr.errors': MagicMock(),
}
with isolated_sys_modules(mocks):
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
mock_app = MagicMock()
requester = OpenAIChatCompletions(mock_app, {})
return requester
def test_infer_embedding_from_name(self):
"""Infer embedding type from model name."""
requester = self._create_requester_with_mocks()
assert requester._infer_model_type('text-embedding-ada-002') == 'embedding'
assert requester._infer_model_type('bge-large-en') == 'embedding'
assert requester._infer_model_type('e5-base') == 'embedding'
assert requester._infer_model_type('m3e-base') == 'embedding'
def test_infer_llm_from_name(self):
"""Infer LLM type from model name."""
requester = self._create_requester_with_mocks()
assert requester._infer_model_type('gpt-4') == 'llm'
assert requester._infer_model_type('claude-3-opus') == 'llm'
assert requester._infer_model_type('llama-2-70b') == 'llm'
def test_infer_model_type_none_id(self):
"""Handle None model_id."""
requester = self._create_requester_with_mocks()
result = requester._infer_model_type(None)
assert result == 'llm' # Default
def test_infer_model_type_empty_id(self):
"""Handle empty model_id."""
requester = self._create_requester_with_mocks()
result = requester._infer_model_type('')
assert result == 'llm' # Default
class TestNormalizeModalities:
"""Tests for _normalize_modalities method."""
def _create_requester_with_mocks(self):
mocks = {
'langbot.pkg.core.app': MagicMock(),
'langbot_plugin.api.entities.builtin.resource.tool': MagicMock(),
'langbot_plugin.api.entities.builtin.pipeline.query': MagicMock(),
'langbot_plugin.api.entities.builtin.provider.message': MagicMock(),
'langbot.pkg.provider.modelmgr.errors': MagicMock(),
}
with isolated_sys_modules(mocks):
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
mock_app = MagicMock()
requester = OpenAIChatCompletions(mock_app, {})
return requester
def test_normalize_string_modality(self):
"""Normalize single string modality."""
requester = self._create_requester_with_mocks()
result = requester._normalize_modalities('text,image')
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 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 result == ['text', 'image']
def test_normalize_none(self):
"""Handle None input."""
requester = self._create_requester_with_mocks()
result = requester._normalize_modalities(None)
assert result == []
def test_normalize_arrow_separator(self):
"""Handle arrow separator in modality string."""
requester = self._create_requester_with_mocks()
result = requester._normalize_modalities('text->image')
assert result == ['text', 'image']
class TestParseRerankResponse:
"""Tests for _parse_rerank_response static method."""
def test_parse_cohere_jina_format(self):
"""Parse Cohere/Jina/SiliconFlow format."""
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
data = {
'results': [
{'index': 0, 'relevance_score': 0.95},
{'index': 1, 'relevance_score': 0.80},
]
}
result = OpenAIChatCompletions._parse_rerank_response(data)
assert result == [
{'index': 0, 'relevance_score': 0.95},
{'index': 1, 'relevance_score': 0.80},
]
def test_parse_voyage_format(self):
"""Parse Voyage AI format."""
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
data = {
'data': [
{'index': 0, 'relevance_score': 0.90},
{'index': 2, 'relevance_score': 0.75},
]
}
result = OpenAIChatCompletions._parse_rerank_response(data)
assert result == [
{'index': 0, 'relevance_score': 0.90},
{'index': 2, 'relevance_score': 0.75},
]
def test_parse_dashscope_format(self):
"""Parse DashScope format."""
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
data = {
'output': {
'results': [
{'index': 0, 'relevance_score': 0.85},
]
}
}
result = OpenAIChatCompletions._parse_rerank_response(data)
assert result == [{'index': 0, 'relevance_score': 0.85}]
def test_parse_unknown_format(self):
"""Handle unknown format returns empty list."""
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
data = {'unknown_key': 'value'}
result = OpenAIChatCompletions._parse_rerank_response(data)
assert result == []
def test_parse_empty_results(self):
"""Handle empty results."""
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
data = {'results': []}
result = OpenAIChatCompletions._parse_rerank_response(data)
assert result == []
class TestExtractScanMetadata:
"""Tests for _extract_scan_metadata method."""
def _create_requester_with_mocks(self):
mocks = {
'langbot.pkg.core.app': MagicMock(),
'langbot_plugin.api.entities.builtin.resource.tool': MagicMock(),
'langbot_plugin.api.entities.builtin.pipeline.query': MagicMock(),
'langbot_plugin.api.entities.builtin.provider.message': MagicMock(),
'langbot.pkg.provider.modelmgr.errors': MagicMock(),
}
with isolated_sys_modules(mocks):
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
mock_app = MagicMock()
requester = OpenAIChatCompletions(mock_app, {})
return requester
def test_extract_basic_metadata(self):
"""Extract basic model metadata."""
requester = self._create_requester_with_mocks()
item = {
'id': 'gpt-4',
'name': 'GPT-4 Turbo',
'description': 'Most capable GPT-4 model',
'context_length': 128000,
'owned_by': 'openai',
}
result = requester._extract_scan_metadata(item, 'gpt-4')
assert result['display_name'] == 'GPT-4 Turbo'
assert result['description'] == 'Most capable GPT-4 model'
assert result['context_length'] == 128000
assert result['owned_by'] == 'openai'
def test_extract_metadata_missing_fields(self):
"""Handle missing metadata fields."""
requester = self._create_requester_with_mocks()
item = {'id': 'unknown-model'}
result = requester._extract_scan_metadata(item, 'unknown-model')
assert result['display_name'] is None
assert result['description'] is None
assert result['context_length'] is None
assert result['owned_by'] is None
def test_extract_metadata_top_provider_context(self):
"""Extract context_length from top_provider."""
requester = self._create_requester_with_mocks()
item = {
'id': 'model',
'top_provider': {
'context_length': 4096,
},
}
result = requester._extract_scan_metadata(item, 'model')
assert result['context_length'] == 4096
def test_extract_metadata_empty_strings(self):
"""Handle empty string values."""
requester = self._create_requester_with_mocks()
item = {
'id': 'model',
'name': '', # Empty name
'description': ' ', # Whitespace only
'owned_by': '',
}
result = requester._extract_scan_metadata(item, 'model')
assert result['display_name'] is None
assert result['description'] is None
assert result['owned_by'] is None
def test_extract_metadata_name_matches_id(self):
"""When name equals id, display_name is None."""
requester = self._create_requester_with_mocks()
item = {
'id': 'gpt-4',
'name': 'gpt-4', # Same as id
}
result = requester._extract_scan_metadata(item, 'gpt-4')
assert result['display_name'] is None
@@ -0,0 +1,264 @@
"""Tests for OllamaChatCompletions requester.
Tests model inference, payload construction, and error handling.
"""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
from langbot.pkg.provider.modelmgr.errors import RequesterError
class TestOllamaRequesterConfig:
"""Tests for default config."""
def test_default_config_values(self):
"""Check default_config."""
from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions
assert OllamaChatCompletions.default_config['base_url'] == 'http://127.0.0.1:11434'
assert OllamaChatCompletions.default_config['timeout'] == 120
def test_config_override(self):
"""Config can override defaults."""
from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions
mock_app = MagicMock()
req = OllamaChatCompletions(mock_app, {
'base_url': 'http://custom.ollama:11434',
'timeout': 300,
})
assert req.requester_cfg['base_url'] == 'http://custom.ollama:11434'
assert req.requester_cfg['timeout'] == 300
class TestOllamaInferModelType:
"""Tests for _infer_model_type pure function."""
@pytest.fixture
def requester(self):
from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions
return OllamaChatCompletions(MagicMock(), {})
def test_infer_embedding_from_name(self, requester):
"""Embedding keywords return 'embedding'."""
assert requester._infer_model_type('nomic-embed-text') == 'embedding'
assert requester._infer_model_type('bge-large') == 'embedding'
assert requester._infer_model_type('text-embedding') == 'embedding'
def test_infer_llm_from_name(self, requester):
"""Non-embedding keywords return 'llm'."""
assert requester._infer_model_type('llama2') == 'llm'
assert requester._infer_model_type('mistral') == 'llm'
assert requester._infer_model_type('codellama') == 'llm'
def test_infer_model_type_none(self, requester):
"""None model_id returns 'llm'."""
assert requester._infer_model_type(None) == 'llm'
def test_infer_model_type_empty(self, requester):
"""Empty model_id returns 'llm'."""
assert requester._infer_model_type('') == 'llm'
class TestOllamaInferModelAbilities:
"""Tests for _infer_model_abilities pure function."""
@pytest.fixture
def requester(self):
from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions
return OllamaChatCompletions(MagicMock(), {})
def test_infer_vision_ability(self, requester):
"""Vision keywords add 'vision' ability."""
item = {
'details': {
'family': 'llava',
}
}
abilities = requester._infer_model_abilities(item, 'llava-v1.5')
assert 'vision' in abilities
def test_infer_vision_from_model_id(self, requester):
"""Vision keywords in model_id add 'vision' ability."""
item = {}
abilities = requester._infer_model_abilities(item, 'llava-7b')
assert 'vision' in abilities
def test_infer_func_call_ability(self, requester):
"""Tool/function keywords add 'func_call' ability."""
item = {
'details': {
'families': ['tools'],
}
}
abilities = requester._infer_model_abilities(item, 'model')
assert 'func_call' in abilities
def test_infer_no_abilities(self, requester):
"""No matching keywords returns empty abilities."""
item = {
'details': {
'family': 'llama',
}
}
abilities = requester._infer_model_abilities(item, 'llama-2')
assert len(abilities) == 0
def test_infer_multiple_abilities(self, requester):
"""Multiple keywords can add multiple abilities."""
item = {
'details': {
'family': 'vision',
'families': ['tools'],
}
}
abilities = requester._infer_model_abilities(item, 'vision-tool-model')
assert 'vision' in abilities
assert 'func_call' in abilities
class TestOllamaMakeMessage:
"""Tests for _make_msg response parsing."""
@pytest.fixture
def requester(self):
from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions
return OllamaChatCompletions(MagicMock(), {})
def _create_ollama_response(self, content, tool_calls=None):
"""Helper to create mock ollama response."""
import ollama
mock_response = MagicMock(spec=ollama.ChatResponse)
mock_message = MagicMock(spec=ollama.Message)
mock_message.content = content
mock_message.tool_calls = tool_calls
mock_response.message = mock_message
return mock_response
@pytest.mark.asyncio
async def test_make_msg_text_content(self, requester):
"""Text content is extracted."""
mock_response = self._create_ollama_response('Hello world')
result = await requester._make_msg(mock_response)
assert result.content == 'Hello world'
assert result.role == 'assistant'
@pytest.mark.asyncio
async def test_make_msg_with_tool_calls(self, requester):
"""Tool calls are parsed."""
mock_tool_call = MagicMock()
mock_tool_call.function = MagicMock()
mock_tool_call.function.name = 'get_weather'
mock_tool_call.function.arguments = {'location': 'Beijing'}
mock_response = self._create_ollama_response('', tool_calls=[mock_tool_call])
result = await requester._make_msg(mock_response)
assert result.tool_calls is not None
assert len(result.tool_calls) == 1
assert result.tool_calls[0].function.name == 'get_weather'
# Arguments should be JSON string
assert isinstance(result.tool_calls[0].function.arguments, str)
@pytest.mark.asyncio
async def test_make_msg_empty_message_raises(self, requester):
"""Empty message raises ValueError."""
mock_response = MagicMock()
mock_response.message = None
with pytest.raises(ValueError, match='message'):
await requester._make_msg(mock_response)
class TestOllamaErrorHandling:
"""Tests for error handling branches."""
@pytest.fixture
def mock_app(self):
app = MagicMock()
app.tool_mgr = MagicMock()
app.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=[])
return app
@pytest.fixture
def requester_with_mocked_client(self, mock_app):
from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions
req = OllamaChatCompletions(mock_app, {})
req.client = MagicMock()
req.client.chat = AsyncMock()
return req
@pytest.fixture
def mock_model(self):
model = MagicMock()
model.model_entity = MagicMock()
model.model_entity.name = 'llama2'
model.provider = MagicMock()
model.provider.token_mgr = MagicMock()
model.provider.token_mgr.get_token = MagicMock(return_value='')
return model
@pytest.fixture
def mock_message(self):
msg = MagicMock()
msg.role = 'user'
msg.content = 'test'
msg.dict = MagicMock(return_value={'role': 'user', 'content': 'test'})
return msg
@pytest.mark.asyncio
async def test_timeout_error(self, requester_with_mocked_client, mock_model, mock_message):
"""TimeoutError is converted to RequesterError."""
requester_with_mocked_client.client.chat = AsyncMock(side_effect=asyncio.TimeoutError())
with pytest.raises(RequesterError) as exc:
await requester_with_mocked_client.invoke_llm(
query=None,
model=mock_model,
messages=[mock_message],
)
assert '超时' in str(exc.value)
class TestOllamaScanModels:
"""Tests for scan_models method."""
@pytest.fixture
def mock_app(self):
return MagicMock()
@pytest.fixture
def requester(self, mock_app):
from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions
req = OllamaChatCompletions(mock_app, {
'base_url': 'http://127.0.0.1:11434',
'timeout': 120,
})
return req
def test_requester_name_constant(self):
"""REQUESTER_NAME constant exists."""
from langbot.pkg.provider.modelmgr.requesters.ollamachat import REQUESTER_NAME
assert REQUESTER_NAME == 'ollama-chat'
@@ -0,0 +1,169 @@
"""Tests for DifyServiceAPIRunner pure utility methods.
Tests the helper methods that don't require real Dify API calls.
"""
from __future__ import annotations
import pytest
class TestDifyExtractTextOutput:
"""Tests for _extract_dify_text_output method."""
def _create_runner(self):
"""Create runner instance."""
from unittest.mock import MagicMock
from langbot.pkg.provider.runners.difysvapi import DifyServiceAPIRunner
mock_app = MagicMock()
pipeline_config = {
'ai': {
'dify-service-api': {
'app-type': 'chat',
'api-key': 'test-key',
'base-url': 'https://api.dify.ai',
}
},
'output': {'misc': {}}
}
runner = DifyServiceAPIRunner(mock_app, pipeline_config)
runner.dify_client = MagicMock()
return runner
def test_extract_none_value(self):
"""None returns empty string."""
runner = self._create_runner()
result = runner._extract_dify_text_output(None)
assert result == ''
def test_extract_string_value(self):
"""Plain string is returned."""
runner = self._create_runner()
result = runner._extract_dify_text_output('plain text')
assert result == 'plain text'
def test_extract_dict_with_content(self):
"""Dict with 'content' key extracts content."""
runner = self._create_runner()
result = runner._extract_dify_text_output({'content': 'extracted content'})
assert result == 'extracted content'
def test_extract_dict_without_content(self):
"""Dict without 'content' key is JSON dumped."""
runner = self._create_runner()
result = runner._extract_dify_text_output({'key': 'value'})
assert 'key' in result
assert 'value' in result
def test_extract_json_string_with_content(self):
"""JSON string with 'content' key extracts content."""
runner = self._create_runner()
result = runner._extract_dify_text_output('{"content": "json content"}')
assert result == 'json content'
def test_extract_json_string_without_content(self):
"""JSON string without 'content' key returns original."""
runner = self._create_runner()
result = runner._extract_dify_text_output('{"other": "value"}')
assert '{"other": "value"}' in result
def test_extract_whitespace_string(self):
"""Whitespace string returns empty."""
runner = self._create_runner()
result = runner._extract_dify_text_output(' ')
assert result == ''
class TestDifyRunnerConfigValidation:
"""Tests for runner config validation."""
def test_invalid_app_type_raises(self):
"""Invalid app-type raises DifyAPIError."""
from unittest.mock import MagicMock
from langbot.pkg.provider.runners.difysvapi import DifyServiceAPIRunner
from langbot.libs.dify_service_api.v1.errors import DifyAPIError
mock_app = MagicMock()
pipeline_config = {
'ai': {
'dify-service-api': {
'app-type': 'invalid-type',
'api-key': 'test',
'base-url': 'https://api.dify.ai',
}
},
'output': {'misc': {}}
}
with pytest.raises(DifyAPIError, match='不支持'):
DifyServiceAPIRunner(mock_app, pipeline_config)
def test_valid_app_types(self):
"""Valid app-types don't raise."""
from unittest.mock import MagicMock
from langbot.pkg.provider.runners.difysvapi import DifyServiceAPIRunner
mock_app = MagicMock()
for app_type in ['chat', 'agent', 'workflow']:
pipeline_config = {
'ai': {
'dify-service-api': {
'app-type': app_type,
'api-key': 'test',
'base-url': 'https://api.dify.ai',
}
},
'output': {'misc': {}}
}
runner = DifyServiceAPIRunner(mock_app, pipeline_config)
# Should not raise
assert runner is not None
class TestDifyRunnerInit:
"""Tests for runner initialization."""
def test_runner_stores_config(self):
"""Runner stores pipeline_config."""
from unittest.mock import MagicMock
from langbot.pkg.provider.runners.difysvapi import DifyServiceAPIRunner
mock_app = MagicMock()
pipeline_config = {
'ai': {
'dify-service-api': {
'app-type': 'chat',
'api-key': 'test-key',
'base-url': 'https://api.dify.ai',
}
},
'output': {'misc': {}}
}
runner = DifyServiceAPIRunner(mock_app, pipeline_config)
assert runner.pipeline_config == pipeline_config
assert runner.ap == mock_app
@@ -0,0 +1,788 @@
"""
Unit tests for ModelManager in provider/modelmgr.
Tests model configuration management, requester selection, provider loading,
and error handling without calling real LLM APIs.
"""
from __future__ import annotations
import pytest
from unittest.mock import Mock
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
from langbot.pkg.provider.modelmgr import requester
from langbot.pkg.entity.persistence import model as persistence_model
from langbot.pkg.entity.errors import provider as provider_errors
from langbot.pkg.provider.modelmgr import token
from tests.unit_tests.provider.conftest import _make_mock_result, _make_row_mock
# ============================================================================
# ModelManager Initialization Tests
# ============================================================================
@pytest.mark.asyncio
async def test_model_manager_initialize_with_fake_requesters(fake_requester_registry):
"""Test ModelManager initializes with fake requester registry."""
model_mgr = fake_requester_registry
await model_mgr.initialize()
assert 'fake-requester' in model_mgr.requester_dict
assert 'another-fake-requester' in model_mgr.requester_dict
assert model_mgr.requester_dict['fake-requester'] is not None
assert len(model_mgr.requester_components) == 2
@pytest.mark.asyncio
async def test_model_manager_initialize_empty_registry(mock_app_for_modelmgr):
"""Test ModelManager handles empty requester registry."""
app = mock_app_for_modelmgr
app.discover.get_components_by_kind = Mock(return_value=[])
model_mgr = ModelManager(app)
await model_mgr.initialize()
assert model_mgr.requester_dict == {}
assert len(model_mgr.requester_components) == 0
@pytest.mark.asyncio
async def test_model_manager_skips_space_sync_when_disabled(mock_app_for_modelmgr):
"""Test ModelManager skips space sync when disabled in config."""
app = mock_app_for_modelmgr
app.instance_config.data = {'space': {'disable_models_service': True}}
model_mgr = ModelManager(app)
await model_mgr.initialize()
# Should not call space_service if disabled
app.space_service.get_models.assert_not_called()
# ============================================================================
# Model Loading Tests
# ============================================================================
@pytest.mark.asyncio
async def test_model_manager_load_models_from_db(fake_requester_registry, fake_persistence_data):
"""Test ModelManager loads models from database correctly."""
model_mgr = fake_requester_registry
# Setup fake persistence responses - return entities directly (code handles non-Row entities)
async def fake_execute(query):
query_str = str(query)
if 'model_providers' in query_str:
return _make_mock_result(fake_persistence_data['providers'])
elif 'llm_models' in query_str:
return _make_mock_result(fake_persistence_data['llm_models'])
elif 'embedding_models' in query_str:
return _make_mock_result(fake_persistence_data['embedding_models'])
elif 'rerank_models' in query_str:
return _make_mock_result(fake_persistence_data['rerank_models'])
return _make_mock_result([])
model_mgr.ap.persistence_mgr.execute_async = fake_execute
await model_mgr.initialize()
# Check providers loaded
assert len(model_mgr.provider_dict) == 2
assert fake_persistence_data['provider_uuid'] in model_mgr.provider_dict
assert fake_persistence_data['provider_uuid2'] in model_mgr.provider_dict
# Check models loaded
assert len(model_mgr.llm_models) == 2
assert len(model_mgr.embedding_models) == 1
assert len(model_mgr.rerank_models) == 1
@pytest.mark.asyncio
async def test_model_manager_load_provider_unknown_requester(mock_app_for_modelmgr):
"""Test ModelManager raises RequesterNotFoundError for unknown requester."""
app = mock_app_for_modelmgr
app.discover.get_components_by_kind = Mock(return_value=[])
model_mgr = ModelManager(app)
await model_mgr.initialize()
provider_info = {
'uuid': 'unknown-provider',
'name': 'Unknown Provider',
'requester': 'non-existent-requester',
'base_url': 'https://unknown.com',
'api_keys': [],
}
with pytest.raises(provider_errors.RequesterNotFoundError) as exc_info:
await model_mgr.load_provider(provider_info)
assert exc_info.value.requester_name == 'non-existent-requester'
@pytest.mark.asyncio
async def test_model_manager_load_provider_from_dict(fake_requester_registry):
"""Test ModelManager loads provider from dict correctly."""
model_mgr = fake_requester_registry
await model_mgr.initialize()
provider_info = {
'uuid': 'dict-provider-uuid',
'name': 'Dict Provider',
'requester': 'fake-requester',
'base_url': 'https://dict.example.com',
'api_keys': ['dict-key'],
}
runtime_provider = await model_mgr.load_provider(provider_info)
assert runtime_provider.provider_entity.uuid == 'dict-provider-uuid'
assert runtime_provider.provider_entity.name == 'Dict Provider'
assert runtime_provider.token_mgr.name == 'dict-provider-uuid'
assert runtime_provider.token_mgr.tokens == ['dict-key']
assert isinstance(runtime_provider.requester, requester.ProviderAPIRequester)
@pytest.mark.asyncio
async def test_model_manager_load_provider_from_entity(fake_requester_registry, fake_persistence_data):
"""Test ModelManager loads provider from persistence entity."""
model_mgr = fake_requester_registry
await model_mgr.initialize()
provider_entity = fake_persistence_data['providers'][0]
runtime_provider = await model_mgr.load_provider(provider_entity)
assert runtime_provider.provider_entity.uuid == provider_entity.uuid
assert runtime_provider.requester is not None
# ============================================================================
# Model Query Tests
# ============================================================================
@pytest.mark.asyncio
async def test_model_manager_get_model_by_uuid(fake_requester_registry, fake_persistence_data):
"""Test ModelManager.get_model_by_uuid returns correct model."""
model_mgr = fake_requester_registry
async def fake_execute(query):
query_str = str(query)
if 'model_providers' in query_str:
return _make_mock_result(fake_persistence_data['providers'])
elif 'llm_models' in query_str:
return _make_mock_result(fake_persistence_data['llm_models'])
return _make_mock_result([])
model_mgr.ap.persistence_mgr.execute_async = fake_execute
await model_mgr.initialize()
model = await model_mgr.get_model_by_uuid('test-llm-uuid-1')
assert model.model_entity.uuid == 'test-llm-uuid-1'
assert model.model_entity.name == 'TestLLM-1'
@pytest.mark.asyncio
async def test_model_manager_get_model_by_uuid_not_found(fake_requester_registry):
"""Test ModelManager.get_model_by_uuid raises ValueError for unknown model."""
model_mgr = fake_requester_registry
await model_mgr.initialize()
with pytest.raises(ValueError) as exc_info:
await model_mgr.get_model_by_uuid('unknown-model-uuid')
assert 'unknown-model-uuid' in str(exc_info.value)
@pytest.mark.asyncio
async def test_model_manager_get_embedding_model_by_uuid(fake_requester_registry, fake_persistence_data):
"""Test ModelManager.get_embedding_model_by_uuid returns correct model."""
model_mgr = fake_requester_registry
async def fake_execute(query):
query_str = str(query)
if 'model_providers' in query_str:
return _make_mock_result(fake_persistence_data['providers'])
elif 'embedding_models' in query_str:
return _make_mock_result(fake_persistence_data['embedding_models'])
return _make_mock_result([])
model_mgr.ap.persistence_mgr.execute_async = fake_execute
await model_mgr.initialize()
model = await model_mgr.get_embedding_model_by_uuid('test-embedding-uuid-1')
assert model.model_entity.uuid == 'test-embedding-uuid-1'
@pytest.mark.asyncio
async def test_model_manager_get_embedding_model_by_uuid_not_found(fake_requester_registry):
"""Test ModelManager.get_embedding_model_by_uuid raises ValueError."""
model_mgr = fake_requester_registry
await model_mgr.initialize()
with pytest.raises(ValueError):
await model_mgr.get_embedding_model_by_uuid('unknown-embedding-uuid')
@pytest.mark.asyncio
async def test_model_manager_get_rerank_model_by_uuid(fake_requester_registry, fake_persistence_data):
"""Test ModelManager.get_rerank_model_by_uuid returns correct model."""
model_mgr = fake_requester_registry
async def fake_execute(query):
query_str = str(query)
if 'model_providers' in query_str:
return _make_mock_result(fake_persistence_data['providers'])
elif 'rerank_models' in query_str:
return _make_mock_result(fake_persistence_data['rerank_models'])
return _make_mock_result([])
model_mgr.ap.persistence_mgr.execute_async = fake_execute
await model_mgr.initialize()
model = await model_mgr.get_rerank_model_by_uuid('test-rerank-uuid-1')
assert model.model_entity.uuid == 'test-rerank-uuid-1'
@pytest.mark.asyncio
async def test_model_manager_get_rerank_model_by_uuid_not_found(fake_requester_registry):
"""Test ModelManager.get_rerank_model_by_uuid raises ValueError."""
model_mgr = fake_requester_registry
await model_mgr.initialize()
with pytest.raises(ValueError):
await model_mgr.get_rerank_model_by_uuid('unknown-rerank-uuid')
# ============================================================================
# Model Removal Tests
# ============================================================================
@pytest.mark.asyncio
async def test_model_manager_remove_llm_model(fake_requester_registry, fake_persistence_data):
"""Test ModelManager.remove_llm_model removes model correctly."""
model_mgr = fake_requester_registry
async def fake_execute(query):
query_str = str(query)
if 'model_providers' in query_str:
return _make_mock_result(fake_persistence_data['providers'])
elif 'llm_models' in query_str:
return _make_mock_result(fake_persistence_data['llm_models'])
return _make_mock_result([])
model_mgr.ap.persistence_mgr.execute_async = fake_execute
await model_mgr.initialize()
assert len(model_mgr.llm_models) == 2
await model_mgr.remove_llm_model('test-llm-uuid-1')
assert len(model_mgr.llm_models) == 1
assert model_mgr.llm_models[0].model_entity.uuid == 'test-llm-uuid-2'
@pytest.mark.asyncio
async def test_model_manager_remove_llm_model_not_found(fake_requester_registry, fake_persistence_data):
"""Test ModelManager.remove_llm_model handles unknown model gracefully."""
model_mgr = fake_requester_registry
async def fake_execute(query):
query_str = str(query)
if 'model_providers' in query_str:
return _make_mock_result(fake_persistence_data['providers'])
elif 'llm_models' in query_str:
return _make_mock_result(fake_persistence_data['llm_models'])
return _make_mock_result([])
model_mgr.ap.persistence_mgr.execute_async = fake_execute
await model_mgr.initialize()
original_count = len(model_mgr.llm_models)
# Removing unknown model should do nothing (no error)
await model_mgr.remove_llm_model('unknown-model-uuid')
assert len(model_mgr.llm_models) == original_count
@pytest.mark.asyncio
async def test_model_manager_remove_embedding_model(fake_requester_registry, fake_persistence_data):
"""Test ModelManager.remove_embedding_model removes model correctly."""
model_mgr = fake_requester_registry
async def fake_execute(query):
query_str = str(query)
if 'model_providers' in query_str:
return _make_mock_result(fake_persistence_data['providers'])
elif 'embedding_models' in query_str:
return _make_mock_result(fake_persistence_data['embedding_models'])
return _make_mock_result([])
model_mgr.ap.persistence_mgr.execute_async = fake_execute
await model_mgr.initialize()
assert len(model_mgr.embedding_models) == 1
await model_mgr.remove_embedding_model('test-embedding-uuid-1')
assert len(model_mgr.embedding_models) == 0
@pytest.mark.asyncio
async def test_model_manager_remove_rerank_model(fake_requester_registry, fake_persistence_data):
"""Test ModelManager.remove_rerank_model removes model correctly."""
model_mgr = fake_requester_registry
async def fake_execute(query):
query_str = str(query)
if 'model_providers' in query_str:
return _make_mock_result(fake_persistence_data['providers'])
elif 'rerank_models' in query_str:
return _make_mock_result(fake_persistence_data['rerank_models'])
return _make_mock_result([])
model_mgr.ap.persistence_mgr.execute_async = fake_execute
await model_mgr.initialize()
assert len(model_mgr.rerank_models) == 1
await model_mgr.remove_rerank_model('test-rerank-uuid-1')
assert len(model_mgr.rerank_models) == 0
@pytest.mark.asyncio
async def test_model_manager_remove_provider(fake_requester_registry, fake_persistence_data):
"""Test ModelManager.remove_provider removes provider correctly."""
model_mgr = fake_requester_registry
async def fake_execute(query):
query_str = str(query)
if 'model_providers' in query_str:
return _make_mock_result(fake_persistence_data['providers'])
elif 'llm_models' in query_str:
return _make_mock_result(fake_persistence_data['llm_models'])
return _make_mock_result([])
model_mgr.ap.persistence_mgr.execute_async = fake_execute
await model_mgr.initialize()
assert fake_persistence_data['provider_uuid'] in model_mgr.provider_dict
await model_mgr.remove_provider(fake_persistence_data['provider_uuid'])
assert fake_persistence_data['provider_uuid'] not in model_mgr.provider_dict
# ============================================================================
# Requester Info Tests
# ============================================================================
def test_model_manager_get_available_requesters_info(fake_requester_registry):
"""Test ModelManager.get_available_requesters_info returns correct info."""
model_mgr = fake_requester_registry
model_mgr.requester_components = []
info = model_mgr.get_available_requesters_info('')
assert info == []
def test_model_manager_get_available_requesters_info_with_type_filter(fake_requester_registry):
"""Test ModelManager.get_available_requesters_info filters by model type."""
model_mgr = fake_requester_registry
from langbot.pkg.discover import engine as discover_engine
manifest = {
'apiVersion': 'v1',
'kind': 'LLMAPIRequester',
'metadata': {'name': 'test-req', 'label': {'en_US': 'Test'}, 'description': {'en_US': 'Test'}},
'spec': {'support_type': ['chat', 'embedding']},
'execution': {'python': {'path': 'fake', 'attr': 'FakeClass'}},
}
component = discover_engine.Component(owner='test', manifest=manifest, rel_path='fake.yaml')
model_mgr.requester_components = [component]
# Filter by chat type
info = model_mgr.get_available_requesters_info('chat')
assert len(info) == 1
assert info[0]['name'] == 'test-req'
# Filter by unsupported type
info = model_mgr.get_available_requesters_info('rerank')
assert len(info) == 0
def test_model_manager_get_available_requester_info_by_name(fake_requester_registry):
"""Test ModelManager.get_available_requester_info_by_name returns correct info."""
model_mgr = fake_requester_registry
from langbot.pkg.discover import engine as discover_engine
manifest = {
'apiVersion': 'v1',
'kind': 'LLMAPIRequester',
'metadata': {'name': 'named-req', 'label': {'en_US': 'Named'}, 'description': {'en_US': 'Named'}},
'spec': {'support_type': ['chat']},
'execution': {'python': {'path': 'fake', 'attr': 'FakeClass'}},
}
component = discover_engine.Component(owner='test', manifest=manifest, rel_path='fake.yaml')
model_mgr.requester_components = [component]
info = model_mgr.get_available_requester_info_by_name('named-req')
assert info is not None
assert info['name'] == 'named-req'
info = model_mgr.get_available_requester_info_by_name('unknown-req')
assert info is None
def test_model_manager_get_available_requester_manifest_by_name(fake_requester_registry):
"""Test ModelManager.get_available_requester_manifest_by_name returns component."""
model_mgr = fake_requester_registry
from langbot.pkg.discover import engine as discover_engine
manifest = {
'apiVersion': 'v1',
'kind': 'LLMAPIRequester',
'metadata': {'name': 'manifest-req', 'label': {'en_US': 'Manifest'}, 'description': {'en_US': 'Manifest'}},
'spec': {'support_type': ['chat']},
'execution': {'python': {'path': 'fake', 'attr': 'FakeClass'}},
}
component = discover_engine.Component(owner='test', manifest=manifest, rel_path='fake.yaml')
model_mgr.requester_components = [component]
comp = model_mgr.get_available_requester_manifest_by_name('manifest-req')
assert comp is not None
assert comp.metadata.name == 'manifest-req'
comp = model_mgr.get_available_requester_manifest_by_name('unknown-req')
assert comp is None
# ============================================================================
# Temporary Runtime Model Tests
# ============================================================================
@pytest.mark.asyncio
async def test_model_manager_init_temporary_runtime_llm_model(fake_requester_registry):
"""Test ModelManager.init_temporary_runtime_llm_model creates model correctly."""
model_mgr = fake_requester_registry
await model_mgr.initialize()
model_info = {
'uuid': 'temp-model-uuid',
'name': 'TempModel',
'provider': {
'uuid': 'temp-provider-uuid',
'name': 'Temp Provider',
'requester': 'fake-requester',
'base_url': 'https://temp.example.com',
'api_keys': ['temp-key'],
},
'abilities': ['func_call'],
'extra_args': {'temperature': 0.5},
}
runtime_model = await model_mgr.init_temporary_runtime_llm_model(model_info)
assert runtime_model.model_entity.uuid == 'temp-model-uuid'
assert runtime_model.model_entity.name == 'TempModel'
assert runtime_model.provider.provider_entity.uuid == 'temp-provider-uuid'
assert runtime_model.provider.token_mgr.tokens == ['temp-key']
@pytest.mark.asyncio
async def test_model_manager_init_temporary_runtime_embedding_model(fake_requester_registry):
"""Test ModelManager.init_temporary_runtime_embedding_model creates model correctly."""
model_mgr = fake_requester_registry
await model_mgr.initialize()
model_info = {
'uuid': 'temp-embedding-uuid',
'name': 'TempEmbedding',
'provider': {
'uuid': 'temp-provider-uuid',
'name': 'Temp Provider',
'requester': 'fake-requester',
'base_url': 'https://temp.example.com',
'api_keys': [],
},
'extra_args': {'dimensions': 512},
}
runtime_model = await model_mgr.init_temporary_runtime_embedding_model(model_info)
assert runtime_model.model_entity.uuid == 'temp-embedding-uuid'
assert runtime_model.model_entity.name == 'TempEmbedding'
@pytest.mark.asyncio
async def test_model_manager_init_temporary_runtime_rerank_model(fake_requester_registry):
"""Test ModelManager.init_temporary_runtime_rerank_model creates model correctly."""
model_mgr = fake_requester_registry
await model_mgr.initialize()
model_info = {
'uuid': 'temp-rerank-uuid',
'name': 'TempRerank',
'provider': {
'uuid': 'temp-provider-uuid',
'name': 'Temp Provider',
'requester': 'fake-requester',
'base_url': 'https://temp.example.com',
'api_keys': [],
},
'extra_args': {},
}
runtime_model = await model_mgr.init_temporary_runtime_rerank_model(model_info)
assert runtime_model.model_entity.uuid == 'temp-rerank-uuid'
assert runtime_model.model_entity.name == 'TempRerank'
# ============================================================================
# Provider Reload Tests
# ============================================================================
@pytest.mark.asyncio
async def test_model_manager_reload_provider(fake_requester_registry, fake_persistence_data):
"""Test ModelManager.reload_provider reloads provider and updates model refs."""
model_mgr = fake_requester_registry
async def fake_execute(query):
query_str = str(query)
if 'model_providers' in query_str:
# For initial load - return all providers
rows = [_make_row_mock(p) for p in fake_persistence_data['providers']]
return _make_mock_result(rows)
elif 'llm_models' in query_str:
rows = [_make_row_mock(m) for m in fake_persistence_data['llm_models']]
return _make_mock_result(rows)
elif 'embedding_models' in query_str:
rows = [_make_row_mock(m) for m in fake_persistence_data['embedding_models']]
return _make_mock_result(rows)
elif 'rerank_models' in query_str:
rows = [_make_row_mock(m) for m in fake_persistence_data['rerank_models']]
return _make_mock_result(rows)
return _make_mock_result([])
model_mgr.ap.persistence_mgr.execute_async = fake_execute
await model_mgr.initialize()
original_provider = model_mgr.provider_dict[fake_persistence_data['provider_uuid']]
original_base_url = original_provider.provider_entity.base_url
# Setup for reload - return updated provider
async def reload_execute(query):
updated_provider = persistence_model.ModelProvider(
uuid=fake_persistence_data['provider_uuid'],
name='Updated Provider',
requester='fake-requester',
base_url='https://updated.example.com',
api_keys=['updated-key'],
)
return _make_mock_result([_make_row_mock(updated_provider)], first_item=_make_row_mock(updated_provider))
model_mgr.ap.persistence_mgr.execute_async = reload_execute
await model_mgr.reload_provider(fake_persistence_data['provider_uuid'])
updated_provider = model_mgr.provider_dict[fake_persistence_data['provider_uuid']]
assert updated_provider.provider_entity.base_url == 'https://updated.example.com'
assert updated_provider.provider_entity.base_url != original_base_url
@pytest.mark.asyncio
async def test_model_manager_reload_provider_not_found(fake_requester_registry):
"""Test ModelManager.reload_provider raises ProviderNotFoundError."""
model_mgr = fake_requester_registry
await model_mgr.initialize()
async def fake_execute(query):
return _make_mock_result([], first_item=None)
model_mgr.ap.persistence_mgr.execute_async = fake_execute
with pytest.raises(provider_errors.ProviderNotFoundError) as exc_info:
await model_mgr.reload_provider('unknown-provider-uuid')
assert exc_info.value.provider_name == 'unknown-provider-uuid'
# ============================================================================
# Model Load with Provider Tests
# ============================================================================
@pytest.mark.asyncio
async def test_model_manager_load_llm_model_with_provider(fake_requester_registry, fake_persistence_data, runtime_provider):
"""Test ModelManager.load_llm_model_with_provider creates RuntimeLLMModel."""
model_mgr = fake_requester_registry
model_entity = fake_persistence_data['llm_models'][0]
runtime_model = await model_mgr.load_llm_model_with_provider(model_entity, runtime_provider)
assert runtime_model.model_entity.uuid == model_entity.uuid
assert runtime_model.provider is runtime_provider
@pytest.mark.asyncio
async def test_model_manager_load_llm_model_with_provider_from_row(fake_requester_registry, fake_persistence_data, runtime_provider):
"""Test ModelManager.load_llm_model_with_provider handles Row objects."""
model_mgr = fake_requester_registry
model_entity = fake_persistence_data['llm_models'][0]
row_mock = _make_row_mock(model_entity)
runtime_model = await model_mgr.load_llm_model_with_provider(row_mock, runtime_provider)
assert runtime_model.model_entity.uuid == model_entity.uuid
@pytest.mark.asyncio
async def test_model_manager_load_embedding_model_with_provider(fake_requester_registry, fake_persistence_data, runtime_provider):
"""Test ModelManager.load_embedding_model_with_provider creates RuntimeEmbeddingModel."""
model_mgr = fake_requester_registry
model_entity = fake_persistence_data['embedding_models'][0]
runtime_model = await model_mgr.load_embedding_model_with_provider(model_entity, runtime_provider)
assert runtime_model.model_entity.uuid == model_entity.uuid
assert runtime_model.provider is runtime_provider
@pytest.mark.asyncio
async def test_model_manager_load_rerank_model_with_provider(fake_requester_registry, fake_persistence_data):
"""Test ModelManager.load_rerank_model_with_provider creates RuntimeRerankModel."""
model_mgr = fake_requester_registry
await model_mgr.initialize()
provider_entity = fake_persistence_data['providers'][1]
token_mgr = token.TokenManager(name=provider_entity.uuid, tokens=provider_entity.api_keys or [])
requester_inst = model_mgr.requester_dict['another-fake-requester'](
ap=model_mgr.ap, config={'base_url': provider_entity.base_url}
)
await requester_inst.initialize()
provider = requester.RuntimeProvider(
provider_entity=provider_entity,
token_mgr=token_mgr,
requester=requester_inst,
)
model_entity = fake_persistence_data['rerank_models'][0]
runtime_model = await model_mgr.load_rerank_model_with_provider(model_entity, provider)
assert runtime_model.model_entity.uuid == model_entity.uuid
assert runtime_model.provider is provider
# ============================================================================
# Missing Provider Warning Tests
# ============================================================================
@pytest.mark.asyncio
async def test_model_manager_logs_warning_for_missing_provider(fake_requester_registry):
"""Test ModelManager logs warning when model's provider is missing."""
model_mgr = fake_requester_registry
async def fake_execute(query):
query_str = str(query)
if 'model_providers' in query_str:
# Return empty providers
return _make_mock_result([])
elif 'llm_models' in query_str:
# Return model with missing provider
fake_model = persistence_model.LLMModel(
uuid='model-with-missing-provider',
name='MissingProviderModel',
provider_uuid='missing-provider-uuid',
abilities=[],
extra_args={},
)
return _make_mock_result([_make_row_mock(fake_model)])
return _make_mock_result([])
model_mgr.ap.persistence_mgr.execute_async = fake_execute
await model_mgr.initialize()
# Should have logged warning and skipped the model
assert len(model_mgr.llm_models) == 0
model_mgr.ap.logger.warning.assert_called()
@pytest.mark.asyncio
async def test_model_manager_handles_requester_not_found_gracefully(fake_requester_registry):
"""Test ModelManager handles RequesterNotFoundError during provider load."""
model_mgr = fake_requester_registry
async def fake_execute(query):
query_str = str(query)
if 'model_providers' in query_str:
# Return provider with unknown requester
fake_provider = persistence_model.ModelProvider(
uuid='provider-with-unknown-requester',
name='Unknown Requester Provider',
requester='unknown-requester-name',
base_url='https://unknown.com',
api_keys=[],
)
return _make_mock_result([_make_row_mock(fake_provider)])
elif 'llm_models' in query_str:
fake_model = persistence_model.LLMModel(
uuid='model-uuid',
name='Model',
provider_uuid='provider-with-unknown-requester',
abilities=[],
extra_args={},
)
return _make_mock_result([_make_row_mock(fake_model)])
return _make_mock_result([])
model_mgr.ap.persistence_mgr.execute_async = fake_execute
await model_mgr.initialize()
# Provider should be skipped
assert len(model_mgr.provider_dict) == 0
assert len(model_mgr.llm_models) == 0
model_mgr.ap.logger.warning.assert_called()
# ============================================================================
# Error Classes Tests
# ============================================================================
def test_requester_not_found_error_str():
"""Test RequesterNotFoundError string representation."""
error = provider_errors.RequesterNotFoundError('test-requester')
assert str(error) == 'Requester test-requester not found'
assert error.requester_name == 'test-requester'
def test_provider_not_found_error_str():
"""Test ProviderNotFoundError string representation."""
error = provider_errors.ProviderNotFoundError('test-provider')
assert str(error) == 'Provider test-provider not found'
assert error.provider_name == 'test-provider'
@@ -11,10 +11,14 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session
from langbot.pkg.api.http.service.model import _runtime_model_data
from langbot.pkg.api.http.service.provider import ModelProviderService
from langbot.pkg.entity.persistence import model as persistence_model
from langbot.pkg.pipeline.preproc.preproc import PreProcessor
from langbot.pkg.provider.modelmgr import requester
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
from langbot.pkg.provider.modelmgr.requesters.modelscopechatcmpl import ModelScopeChatCompletions
from langbot.pkg.provider.modelmgr.token import TokenManager
from langbot.pkg.provider.runners.localagent import LocalAgentRunner
@@ -58,6 +62,102 @@ def test_runtime_rerank_model_data_preserves_uuid_after_update_payload_uuid_remo
assert runtime_entity.name == 'rerank-model'
def test_normalize_space_provider_api_keys_filters_blank_values():
assert ModelProviderService._normalize_api_keys('space-key') == ['space-key']
assert ModelProviderService._normalize_api_keys(' trimmed-key ') == ['trimmed-key']
assert ModelProviderService._normalize_api_keys('') == []
assert ModelProviderService._normalize_api_keys(' ') == []
assert ModelProviderService._normalize_api_keys(None) == []
assert ModelProviderService._normalize_api_keys([' first-key ', '', 'first-key', 'second-key']) == [
'first-key',
'second-key',
]
def test_token_manager_filters_blank_and_duplicate_tokens():
token_mgr = TokenManager('provider-uuid', [' first-key ', '', 'first-key', 'second-key', ' '])
assert token_mgr.tokens == ['first-key', 'second-key']
assert token_mgr.get_token() == 'first-key'
def test_token_manager_next_token_ignores_empty_token_list():
token_mgr = TokenManager('provider-uuid', [])
token_mgr.next_token()
assert token_mgr.get_token() == ''
assert token_mgr.using_token_index == 0
@pytest.mark.asyncio
async def test_openai_requester_initialize_uses_placeholder_api_key(monkeypatch):
captured_kwargs = {}
def fake_client(**kwargs):
captured_kwargs.update(kwargs)
return SimpleNamespace(**kwargs)
monkeypatch.setattr('langbot.pkg.provider.modelmgr.requesters.chatcmpl.openai.AsyncClient', fake_client)
monkeypatch.setattr('langbot.pkg.provider.modelmgr.requesters.chatcmpl.httpx.AsyncClient', fake_client)
requester_inst = OpenAIChatCompletions(ap=SimpleNamespace(), config={})
await requester_inst.initialize()
assert captured_kwargs['api_key'] == OpenAIChatCompletions.init_api_key
@pytest.mark.asyncio
async def test_modelscope_requester_initialize_uses_placeholder_api_key(monkeypatch):
captured_kwargs = {}
def fake_client(**kwargs):
captured_kwargs.update(kwargs)
return SimpleNamespace(**kwargs)
monkeypatch.setattr('langbot.pkg.provider.modelmgr.requesters.modelscopechatcmpl.openai.AsyncClient', fake_client)
monkeypatch.setattr('langbot.pkg.provider.modelmgr.requesters.modelscopechatcmpl.httpx.AsyncClient', fake_client)
requester_inst = ModelScopeChatCompletions(ap=SimpleNamespace(), config={})
await requester_inst.initialize()
assert captured_kwargs['api_key'] == ModelScopeChatCompletions.init_api_key
@pytest.mark.asyncio
async def test_openai_embedding_call_overrides_placeholder_api_key():
captured_request = {}
async def fake_create(**kwargs):
captured_request['api_key'] = fake_client.api_key
captured_request['kwargs'] = kwargs
return SimpleNamespace(
data=[SimpleNamespace(embedding=[0.1, 0.2])],
usage=SimpleNamespace(prompt_tokens=3, total_tokens=3),
)
fake_client = SimpleNamespace(
api_key=OpenAIChatCompletions.init_api_key,
embeddings=SimpleNamespace(create=fake_create),
)
requester_inst = OpenAIChatCompletions(ap=SimpleNamespace(), config={})
requester_inst.client = fake_client
embeddings, usage_info = await requester_inst.invoke_embedding(
model=requester.RuntimeEmbeddingModel(
model_entity=SimpleNamespace(name='text-embedding-3-small', extra_args={}),
provider=SimpleNamespace(token_mgr=TokenManager('provider-uuid', [' runtime-key ', '', 'runtime-key'])),
),
input_text=['hello'],
)
assert captured_request['api_key'] == 'runtime-key'
assert captured_request['kwargs']['model'] == 'text-embedding-3-small'
assert embeddings == [[0.1, 0.2]]
assert usage_info == {'prompt_tokens': 3, 'total_tokens': 3}
@pytest.mark.asyncio
async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline():
from langbot.pkg.api.http.service.model import LLMModelsService
@@ -0,0 +1,633 @@
"""
Unit tests for ProviderAPIRequester base class and runtime entities in provider/modelmgr.
Tests requester initialization, configuration handling, token management,
and runtime model/provider behavior without calling real LLM APIs.
"""
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock
from types import SimpleNamespace
from langbot.pkg.provider.modelmgr import requester
from langbot.pkg.provider.modelmgr import token
from langbot.pkg.entity.persistence import model as persistence_model
from langbot.pkg.provider.modelmgr.errors import RequesterError
# ============================================================================
# ProviderAPIRequester Base Class Tests
# ============================================================================
class TestableRequester(requester.ProviderAPIRequester):
"""Testable requester subclass for testing base class behavior."""
name = 'testable-requester'
default_config = {
'base_url': 'https://default.example.com',
'timeout': 60,
'max_retries': 3,
}
async def invoke_llm(
self,
query,
model: requester.RuntimeLLMModel,
messages: list,
funcs=None,
extra_args={},
remove_think=False,
):
import langbot_plugin.api.entities.builtin.provider.message as provider_message
return provider_message.Message(
role='assistant',
content=[provider_message.ContentElement(type='text', text='Testable response')],
)
def test_requester_base_class_is_abstract():
"""Test ProviderAPIRequester cannot be instantiated directly."""
mock_app = SimpleNamespace()
mock_app.logger = Mock()
# ProviderAPIRequester has abstract methods, but ABCMeta allows instantiation
# if you don't call the abstract methods. Test that it has abstract methods.
assert hasattr(requester.ProviderAPIRequester, 'invoke_llm')
# Check that invoke_llm is abstract
assert hasattr(requester.ProviderAPIRequester.invoke_llm, '__isabstractmethod__')
def test_requester_default_config_merged():
"""Test requester merges default config with provided config."""
mock_app = SimpleNamespace()
mock_app.logger = Mock()
inst = TestableRequester(mock_app, {'base_url': 'https://custom.example.com', 'custom_key': 'custom_value'})
assert inst.requester_cfg['base_url'] == 'https://custom.example.com'
assert inst.requester_cfg['timeout'] == 60 # from default
assert inst.requester_cfg['max_retries'] == 3 # from default
assert inst.requester_cfg['custom_key'] == 'custom_value' # custom added
def test_requester_default_config_not_modified():
"""Test that default_config dict is not modified when merging."""
mock_app = SimpleNamespace()
mock_app.logger = Mock()
inst = TestableRequester(mock_app, {'base_url': 'https://override.example.com'})
assert TestableRequester.default_config['base_url'] == 'https://default.example.com'
assert inst.requester_cfg['base_url'] == 'https://override.example.com'
def test_requester_empty_config_uses_defaults():
"""Test requester uses defaults when empty config provided."""
mock_app = SimpleNamespace()
mock_app.logger = Mock()
inst = TestableRequester(mock_app, {})
assert inst.requester_cfg == inst.default_config
@pytest.mark.asyncio
async def test_requester_initialize_is_callable():
"""Test requester initialize method is callable (default is pass)."""
mock_app = SimpleNamespace()
mock_app.logger = Mock()
inst = TestableRequester(mock_app, {})
await inst.initialize()
# No exception should occur
@pytest.mark.asyncio
async def test_requester_scan_models_not_implemented():
"""Test scan_models raises NotImplementedError by default."""
mock_app = SimpleNamespace()
mock_app.logger = Mock()
inst = TestableRequester(mock_app, {})
await inst.initialize()
with pytest.raises(NotImplementedError) as exc_info:
await inst.scan_models()
assert 'does not support model scanning' in str(exc_info.value)
@pytest.mark.asyncio
async def test_requester_invoke_rerank_not_implemented():
"""Test invoke_rerank raises NotImplementedError by default."""
mock_app = SimpleNamespace()
mock_app.logger = Mock()
inst = TestableRequester(mock_app, {})
await inst.initialize()
# Create fake model
fake_provider_entity = persistence_model.ModelProvider(
uuid='provider-uuid',
name='Provider',
requester='test',
base_url='https://test.com',
api_keys=[],
)
fake_token_mgr = token.TokenManager(name='test', tokens=[])
fake_requester = inst
fake_provider = requester.RuntimeProvider(
provider_entity=fake_provider_entity,
token_mgr=fake_token_mgr,
requester=fake_requester,
)
fake_model_entity = persistence_model.RerankModel(
uuid='model-uuid',
name='Model',
provider_uuid='provider-uuid',
extra_args={},
)
fake_model = requester.RuntimeRerankModel(
model_entity=fake_model_entity,
provider=fake_provider,
)
with pytest.raises(NotImplementedError) as exc_info:
await inst.invoke_rerank(fake_model, 'query', ['doc1', 'doc2'])
assert 'does not support rerank' in str(exc_info.value)
# ============================================================================
# TokenManager Tests
# ============================================================================
def test_token_manager_initial_state():
"""Test TokenManager initial state."""
mgr = token.TokenManager(name='test-manager', tokens=['key1', 'key2', 'key3'])
assert mgr.name == 'test-manager'
assert mgr.tokens == ['key1', 'key2', 'key3']
assert mgr.using_token_index == 0
def test_token_manager_get_token():
"""Test TokenManager.get_token returns current token."""
mgr = token.TokenManager(name='test', tokens=['key1', 'key2'])
assert mgr.get_token() == 'key1'
def test_token_manager_get_token_empty():
"""Test TokenManager.get_token returns empty string when no tokens."""
mgr = token.TokenManager(name='test', tokens=[])
assert mgr.get_token() == ''
def test_token_manager_next_token_cycles():
"""Test TokenManager.next_token cycles through tokens."""
mgr = token.TokenManager(name='test', tokens=['key1', 'key2', 'key3'])
assert mgr.get_token() == 'key1'
mgr.next_token()
assert mgr.get_token() == 'key2'
mgr.next_token()
assert mgr.get_token() == 'key3'
# Should cycle back to first
mgr.next_token()
assert mgr.get_token() == 'key1'
def test_token_manager_next_token_single():
"""Test TokenManager.next_token with single token."""
mgr = token.TokenManager(name='test', tokens=['single-key'])
mgr.next_token()
assert mgr.get_token() == 'single-key'
mgr.next_token()
assert mgr.get_token() == 'single-key'
def test_token_manager_next_token_empty():
"""Test TokenManager.next_token with empty tokens doesn't error."""
mgr = token.TokenManager(name='test', tokens=[])
assert mgr.next_token() is None
assert mgr.get_token() == ''
# ============================================================================
# RuntimeProvider Tests
# ============================================================================
def test_runtime_provider_initialization(runtime_provider, fake_persistence_data):
"""Test RuntimeProvider initialization."""
provider = runtime_provider
provider_entity = fake_persistence_data['providers'][0]
assert provider.provider_entity.uuid == provider_entity.uuid
assert provider.provider_entity.name == provider_entity.name
assert provider.token_mgr.name == provider_entity.uuid
assert provider.token_mgr.tokens == provider_entity.api_keys
assert isinstance(provider.requester, requester.ProviderAPIRequester)
def test_runtime_provider_has_invoke_methods(runtime_provider):
"""Test RuntimeProvider has invoke methods that delegate to requester."""
provider = runtime_provider
assert hasattr(provider, 'invoke_llm')
assert hasattr(provider, 'invoke_llm_stream')
assert hasattr(provider, 'invoke_embedding')
assert hasattr(provider, 'invoke_rerank')
@pytest.mark.asyncio
async def test_runtime_provider_invoke_llm_delegates(runtime_provider, runtime_llm_model):
"""Test RuntimeProvider.invoke_llm delegates to requester."""
provider = runtime_provider
# Track that requester was called
provider.requester._invoke_count = 0
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
# Create minimal query for testing (bypass validation)
query = pipeline_query.Query.model_construct(
query_id='test-query',
launcher_type='person',
launcher_id=12345,
sender_id=12345,
message_chain=None,
message_event=None,
adapter=None,
pipeline_uuid='pipeline-uuid',
bot_uuid='bot-uuid',
pipeline_config={'ai': {}, 'output': {}, 'trigger': {}},
session=None,
prompt=None,
messages=[],
user_message=None,
use_funcs=[],
use_llm_model_uuid=None,
variables={},
resp_messages=[],
resp_message_chain=None,
current_stage_name=None,
)
messages = [provider_message.Message(role='user', content=[provider_message.ContentElement(type='text', text='Hello')])]
result = await provider.invoke_llm(query, runtime_llm_model, messages)
assert provider.requester._invoke_count == 1
assert provider.requester._last_messages == messages
assert provider.requester._last_model == runtime_llm_model
assert result.role == 'assistant'
@pytest.mark.asyncio
async def test_runtime_provider_invoke_llm_stream_yields_chunks(runtime_provider, runtime_llm_model):
"""Test RuntimeProvider.invoke_llm_stream yields chunks from requester."""
provider = runtime_provider
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
query = pipeline_query.Query.model_construct(
query_id='test-stream',
launcher_type='person',
launcher_id=12345,
sender_id=12345,
message_chain=None,
message_event=None,
adapter=None,
pipeline_uuid='pipeline-uuid',
bot_uuid='bot-uuid',
pipeline_config={'ai': {}, 'output': {}, 'trigger': {}},
session=None,
prompt=None,
messages=[],
user_message=None,
use_funcs=[],
use_llm_model_uuid=None,
variables={},
resp_messages=[],
resp_message_chain=None,
current_stage_name=None,
)
messages = [provider_message.Message(role='user', content=[provider_message.ContentElement(type='text', text='Hello')])]
chunks = []
async for chunk in provider.invoke_llm_stream(query, runtime_llm_model, messages):
chunks.append(chunk)
assert len(chunks) == 1
assert chunks[0].role == 'assistant'
@pytest.mark.asyncio
async def test_runtime_provider_invoke_embedding_returns_vectors(runtime_provider, runtime_embedding_model):
"""Test RuntimeProvider.invoke_embedding returns embedding vectors."""
provider = runtime_provider
result = await provider.invoke_embedding(runtime_embedding_model, ['text1', 'text2'])
assert len(result) == 2
assert result[0] == [0.1, 0.2, 0.3]
@pytest.mark.asyncio
async def test_runtime_provider_invoke_rerank_returns_scores(runtime_provider, runtime_rerank_model):
"""Test RuntimeProvider.invoke_rerank returns relevance scores."""
# Need to use the correct provider for rerank model
provider = runtime_rerank_model.provider
result = await provider.invoke_rerank(runtime_rerank_model, 'query', ['doc1', 'doc2', 'doc3'])
assert len(result) == 3
assert result[0]['index'] == 0
assert result[0]['relevance_score'] == 0.9
# ============================================================================
# RuntimeLLMModel Tests
# ============================================================================
def test_runtime_llm_model_initialization(runtime_llm_model, fake_persistence_data):
"""Test RuntimeLLMModel initialization."""
model = runtime_llm_model
model_entity = fake_persistence_data['llm_models'][0]
assert model.model_entity.uuid == model_entity.uuid
assert model.model_entity.name == model_entity.name
assert model.model_entity.abilities == model_entity.abilities
assert model.model_entity.extra_args == model_entity.extra_args
assert model.provider is not None
def test_runtime_llm_model_provider_ref(runtime_llm_model):
"""Test RuntimeLLMModel has correct provider reference."""
model = runtime_llm_model
assert model.provider.provider_entity is not None
assert model.provider.token_mgr is not None
assert model.provider.requester is not None
# ============================================================================
# RuntimeEmbeddingModel Tests
# ============================================================================
def test_runtime_embedding_model_initialization(runtime_embedding_model, fake_persistence_data):
"""Test RuntimeEmbeddingModel initialization."""
model = runtime_embedding_model
model_entity = fake_persistence_data['embedding_models'][0]
assert model.model_entity.uuid == model_entity.uuid
assert model.model_entity.name == model_entity.name
assert model.model_entity.extra_args == model_entity.extra_args
assert model.provider is not None
# ============================================================================
# RuntimeRerankModel Tests
# ============================================================================
def test_runtime_rerank_model_initialization(runtime_rerank_model, fake_persistence_data):
"""Test RuntimeRerankModel initialization."""
model = runtime_rerank_model
model_entity = fake_persistence_data['rerank_models'][0]
assert model.model_entity.uuid == model_entity.uuid
assert model.model_entity.name == model_entity.name
assert model.model_entity.extra_args == model_entity.extra_args
assert model.provider is not None
# ============================================================================
# RequesterError Tests
# ============================================================================
def test_requester_error_message_format():
"""Test RequesterError message format."""
error = RequesterError('API returned 500')
assert '模型请求失败' in str(error)
assert 'API returned 500' in str(error)
def test_requester_error_is_exception():
"""Test RequesterError is Exception subclass."""
error = RequesterError('test')
assert isinstance(error, Exception)
# ============================================================================
# ProviderAPIRequester Config Validation Tests
# ============================================================================
def test_requester_with_missing_base_url():
"""Test requester handles missing base_url in config."""
mock_app = SimpleNamespace()
mock_app.logger = Mock()
# If base_url is in default_config, it will be used
inst = TestableRequester(mock_app, {'timeout': 30})
assert inst.requester_cfg['base_url'] == 'https://default.example.com'
def test_requester_with_none_values():
"""Test requester handles None values in config."""
mock_app = SimpleNamespace()
mock_app.logger = Mock()
inst = TestableRequester(mock_app, {'timeout': None, 'base_url': 'https://test.com'})
# None values are kept in the merged config
assert inst.requester_cfg['timeout'] is None
class RequesterWithNoDefaults(requester.ProviderAPIRequester):
"""Requester with empty defaults for testing."""
name = 'no-defaults-requester'
default_config = {}
async def invoke_llm(self, query, model, messages, funcs=None, extra_args={}, remove_think=False):
pass
def test_requester_empty_defaults_with_empty_config():
"""Test requester with empty defaults and empty config."""
mock_app = SimpleNamespace()
mock_app.logger = Mock()
inst = RequesterWithNoDefaults(mock_app, {})
assert inst.requester_cfg == {}
def test_requester_empty_defaults_with_values():
"""Test requester with empty defaults receives config values."""
mock_app = SimpleNamespace()
mock_app.logger = Mock()
inst = RequesterWithNoDefaults(mock_app, {'base_url': 'https://custom.com', 'api_key': 'key'})
assert inst.requester_cfg['base_url'] == 'https://custom.com'
assert inst.requester_cfg['api_key'] == 'key'
# ============================================================================
# RuntimeProvider Error Handling Tests
# ============================================================================
class ErrorThrowingRequester(requester.ProviderAPIRequester):
"""Requester that throws errors for testing."""
name = 'error-requester'
default_config = {}
async def invoke_llm(self, query, model, messages, funcs=None, extra_args={}, remove_think=False):
raise RequesterError('Simulated API error')
@pytest.mark.asyncio
async def test_runtime_provider_invoke_llm_propagates_error(mock_app_for_modelmgr):
"""Test RuntimeProvider.invoke_llm propagates requester errors."""
mock_app = mock_app_for_modelmgr
# Add monitoring_service for error handling path
mock_app.monitoring_service = AsyncMock()
requester_inst = ErrorThrowingRequester(mock_app, {})
await requester_inst.initialize()
provider_entity = persistence_model.ModelProvider(
uuid='error-provider',
name='Error Provider',
requester='error-requester',
base_url='https://error.com',
api_keys=['error-key'],
)
token_mgr = token.TokenManager(name='error-provider', tokens=['error-key'])
provider = requester.RuntimeProvider(
provider_entity=provider_entity,
token_mgr=token_mgr,
requester=requester_inst,
)
model_entity = persistence_model.LLMModel(
uuid='error-model',
name='Error Model',
provider_uuid='error-provider',
abilities=[],
extra_args={},
)
model = requester.RuntimeLLMModel(model_entity=model_entity, provider=provider)
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
query = pipeline_query.Query.model_construct(
query_id='error-query',
launcher_type='person',
launcher_id=12345,
sender_id=12345,
message_chain=None,
message_event=None,
adapter=None,
pipeline_uuid='pipeline-uuid',
bot_uuid='bot-uuid',
pipeline_config={'ai': {}, 'output': {}, 'trigger': {}},
session=None,
prompt=None,
messages=[],
user_message=None,
use_funcs=[],
use_llm_model_uuid=None,
variables={},
resp_messages=[],
resp_message_chain=None,
current_stage_name=None,
)
messages = [provider_message.Message(role='user', content=[provider_message.ContentElement(type='text', text='Hello')])]
with pytest.raises(RequesterError):
await provider.invoke_llm(query, model, messages)
# ============================================================================
# LLMModelInfo Tests (from entities.py)
# ============================================================================
def test_llm_model_info_basic():
"""Test LLMModelInfo basic structure."""
from langbot.pkg.provider.modelmgr.entities import LLMModelInfo
mock_app = SimpleNamespace()
mock_app.logger = Mock()
fake_requester = TestableRequester(mock_app, {})
fake_token_mgr = token.TokenManager(name='test', tokens=['key'])
info = LLMModelInfo(
name='test-model',
model_name='gpt-4',
token_mgr=fake_token_mgr,
requester=fake_requester,
tool_call_supported=True,
vision_supported=False,
)
assert info.name == 'test-model'
assert info.model_name == 'gpt-4'
assert info.tool_call_supported == True
assert info.vision_supported == False
def test_llm_model_info_optional_fields():
"""Test LLMModelInfo optional fields default values."""
from langbot.pkg.provider.modelmgr.entities import LLMModelInfo
mock_app = SimpleNamespace()
mock_app.logger = Mock()
fake_requester = TestableRequester(mock_app, {})
fake_token_mgr = token.TokenManager(name='test', tokens=['key'])
info = LLMModelInfo(
name='minimal-model',
token_mgr=fake_token_mgr,
requester=fake_requester,
)
assert info.model_name is None
assert info.tool_call_supported == False # default
assert info.vision_supported == False # default
@@ -0,0 +1,321 @@
"""Unit tests for SessionManager.
Tests cover:
- Session creation and retrieval
- Conversation creation with prompts
- Session concurrency semaphore
"""
from __future__ import annotations
import pytest
import asyncio
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.pipeline.query as pipeline_query
def get_session_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.provider.session.sessionmgr')
class TestSessionManagerInit:
"""Tests for SessionManager initialization."""
def test_init_stores_app_reference(self):
"""Test that __init__ stores the Application reference."""
sessionmgr = get_session_module()
mock_app = Mock()
manager = sessionmgr.SessionManager(mock_app)
assert manager.ap is mock_app
def test_init_empty_session_list(self):
"""Test that session_list starts empty."""
sessionmgr = get_session_module()
mock_app = Mock()
manager = sessionmgr.SessionManager(mock_app)
assert manager.session_list == []
@pytest.mark.asyncio
async def test_initialize_empty(self):
"""Test that initialize does nothing (current implementation)."""
sessionmgr = get_session_module()
mock_app = Mock()
manager = sessionmgr.SessionManager(mock_app)
await manager.initialize()
# Should not raise or change state
assert manager.session_list == []
class TestSessionManagerGetSession:
"""Tests for get_session method."""
@pytest.fixture
def mock_app_with_config(self):
"""Create mock app with instance config."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {
'concurrency': {
'session': 5
}
}
return mock_app
@pytest.fixture
def sample_query(self):
"""Create sample query for testing."""
query = Mock(spec=pipeline_query.Query)
query.launcher_type = provider_session.LauncherTypes.PERSON
query.launcher_id = '12345'
query.sender_id = '12345'
return query
@pytest.mark.asyncio
async def test_creates_new_session_when_not_found(self, mock_app_with_config, sample_query):
"""Test that get_session creates new session when not found."""
sessionmgr = get_session_module()
manager = sessionmgr.SessionManager(mock_app_with_config)
session = await manager.get_session(sample_query)
assert session is not None
assert session.launcher_type == sample_query.launcher_type
assert session.launcher_id == sample_query.launcher_id
assert session.sender_id == sample_query.sender_id
assert len(manager.session_list) == 1
@pytest.mark.asyncio
async def test_returns_existing_session_when_found(self, mock_app_with_config, sample_query):
"""Test that get_session returns existing session when found."""
sessionmgr = get_session_module()
manager = sessionmgr.SessionManager(mock_app_with_config)
# First call creates session
session1 = await manager.get_session(sample_query)
# Second call should return same session
session2 = await manager.get_session(sample_query)
assert session1 is session2
assert len(manager.session_list) == 1
@pytest.mark.asyncio
async def test_session_has_semaphore(self, mock_app_with_config, sample_query):
"""Test that created session has semaphore for concurrency."""
sessionmgr = get_session_module()
manager = sessionmgr.SessionManager(mock_app_with_config)
session = await manager.get_session(sample_query)
assert hasattr(session, '_semaphore')
assert session._semaphore is not None
assert isinstance(session._semaphore, asyncio.Semaphore)
@pytest.mark.asyncio
async def test_different_launchers_have_different_sessions(self, mock_app_with_config):
"""Test that different launcher_id creates different sessions."""
sessionmgr = get_session_module()
manager = sessionmgr.SessionManager(mock_app_with_config)
query1 = Mock(spec=pipeline_query.Query)
query1.launcher_type = provider_session.LauncherTypes.PERSON
query1.launcher_id = 'user1'
query1.sender_id = 'user1'
query2 = Mock(spec=pipeline_query.Query)
query2.launcher_type = provider_session.LauncherTypes.PERSON
query2.launcher_id = 'user2'
query2.sender_id = 'user2'
session1 = await manager.get_session(query1)
session2 = await manager.get_session(query2)
assert session1 is not session2
assert len(manager.session_list) == 2
@pytest.mark.asyncio
async def test_different_launcher_types_have_different_sessions(self, mock_app_with_config):
"""Test that different launcher_type creates different sessions."""
sessionmgr = get_session_module()
manager = sessionmgr.SessionManager(mock_app_with_config)
query1 = Mock(spec=pipeline_query.Query)
query1.launcher_type = provider_session.LauncherTypes.PERSON
query1.launcher_id = 'same_id'
query1.sender_id = 'same_id'
query2 = Mock(spec=pipeline_query.Query)
query2.launcher_type = provider_session.LauncherTypes.GROUP
query2.launcher_id = 'same_id'
query2.sender_id = 'same_id'
session1 = await manager.get_session(query1)
session2 = await manager.get_session(query2)
assert session1 is not session2
assert len(manager.session_list) == 2
class TestSessionManagerGetConversation:
"""Tests for get_conversation method."""
@pytest.fixture
def mock_app_with_config(self):
"""Create mock app with instance config."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {
'concurrency': {
'session': 5
}
}
return mock_app
@pytest.fixture
def sample_session(self):
"""Create sample session for testing."""
session = Mock(spec=provider_session.Session)
session.launcher_type = provider_session.LauncherTypes.PERSON
session.launcher_id = '12345'
session.sender_id = '12345'
session.conversations = []
session.using_conversation = None
return session
@pytest.fixture
def sample_query(self):
"""Create sample query for testing."""
query = Mock(spec=pipeline_query.Query)
query.launcher_type = provider_session.LauncherTypes.PERSON
query.launcher_id = '12345'
query.sender_id = '12345'
return query
@pytest.mark.asyncio
async def test_creates_conversation_with_prompt(
self, mock_app_with_config, sample_query, sample_session
):
"""Test that get_conversation creates conversation with prompt."""
sessionmgr = get_session_module()
manager = sessionmgr.SessionManager(mock_app_with_config)
prompt_config = [
{'role': 'system', 'content': 'You are a helpful assistant.'}
]
pipeline_uuid = 'pipeline-123'
bot_uuid = 'bot-123'
conversation = await manager.get_conversation(
sample_query, sample_session, prompt_config, pipeline_uuid, bot_uuid
)
assert conversation is not None
assert conversation.pipeline_uuid == pipeline_uuid
assert conversation.bot_uuid == bot_uuid
assert conversation.prompt is not None
assert len(sample_session.conversations) == 1
@pytest.mark.asyncio
async def test_uses_existing_conversation_when_pipeline_matches(
self, mock_app_with_config, sample_query, sample_session
):
"""Test that get_conversation uses existing conversation when pipeline matches."""
sessionmgr = get_session_module()
manager = sessionmgr.SessionManager(mock_app_with_config)
prompt_config = [
{'role': 'system', 'content': 'You are a helpful assistant.'}
]
pipeline_uuid = 'pipeline-123'
bot_uuid = 'bot-123'
# First call creates conversation
conv1 = await manager.get_conversation(
sample_query, sample_session, prompt_config, pipeline_uuid, bot_uuid
)
# Second call with same pipeline should return same conversation
conv2 = await manager.get_conversation(
sample_query, sample_session, prompt_config, pipeline_uuid, bot_uuid
)
assert conv1 is conv2
assert len(sample_session.conversations) == 1
@pytest.mark.asyncio
async def test_creates_new_conversation_when_pipeline_changes(
self, mock_app_with_config, sample_query, sample_session
):
"""Test that get_conversation creates new conversation when pipeline changes."""
sessionmgr = get_session_module()
manager = sessionmgr.SessionManager(mock_app_with_config)
prompt_config = [
{'role': 'system', 'content': 'You are a helpful assistant.'}
]
# First call with pipeline1
conv1 = await manager.get_conversation(
sample_query, sample_session, prompt_config, 'pipeline-1', 'bot-1'
)
# Second call with different pipeline should create new conversation
conv2 = await manager.get_conversation(
sample_query, sample_session, prompt_config, 'pipeline-2', 'bot-2'
)
assert conv1 is not conv2
assert len(sample_session.conversations) == 2
assert sample_session.using_conversation is conv2
@pytest.mark.asyncio
async def test_conversation_has_empty_messages(
self, mock_app_with_config, sample_query, sample_session
):
"""Test that created conversation has empty messages list."""
sessionmgr = get_session_module()
manager = sessionmgr.SessionManager(mock_app_with_config)
prompt_config = [
{'role': 'system', 'content': 'You are a helpful assistant.'}
]
conversation = await manager.get_conversation(
sample_query, sample_session, prompt_config, 'pipeline-123', 'bot-123'
)
assert conversation.messages == []
@pytest.mark.asyncio
async def test_prompt_messages_from_config(
self, mock_app_with_config, sample_query, sample_session
):
"""Test that prompt messages are created from prompt_config."""
sessionmgr = get_session_module()
manager = sessionmgr.SessionManager(mock_app_with_config)
prompt_config = [
{'role': 'system', 'content': 'System message'},
{'role': 'user', 'content': 'User message'}
]
conversation = await manager.get_conversation(
sample_query, sample_session, prompt_config, 'pipeline-123', 'bot-123'
)
assert conversation.prompt.name == 'default'
assert len(conversation.prompt.messages) == 2
@@ -0,0 +1,336 @@
"""Unit tests for ToolManager.
Tests cover:
- Tool schema generation for OpenAI and Anthropic
- Tool execution dispatch
"""
from __future__ import annotations
import pytest
from unittest.mock import Mock, AsyncMock
from importlib import import_module
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
def get_toolmgr_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.provider.tools.toolmgr')
class TestToolManagerInit:
"""Tests for ToolManager initialization."""
def test_init_stores_app_reference(self):
"""Test that __init__ stores the Application reference."""
toolmgr = get_toolmgr_module()
mock_app = Mock()
manager = toolmgr.ToolManager(mock_app)
assert manager.ap is mock_app
def test_init_no_tool_loaders(self):
"""Test that tool loaders are not initialized before initialize()."""
toolmgr = get_toolmgr_module()
mock_app = Mock()
manager = toolmgr.ToolManager(mock_app)
assert hasattr(manager, 'plugin_tool_loader') is False or manager.plugin_tool_loader is None
class TestToolManagerSchemaGeneration:
"""Tests for tool schema generation methods."""
@pytest.fixture
def mock_app(self):
"""Create mock app."""
mock_app = Mock()
mock_app.logger = Mock()
return mock_app
@pytest.fixture
def sample_tools(self):
"""Create sample LLMTool list for testing."""
def dummy_weather_func(**kwargs):
return "weather result"
def dummy_calc_func(**kwargs):
return "calc result"
tools = [
resource_tool.LLMTool(
name='get_weather',
human_desc='Get current weather for a location',
description='Get current weather for a location',
parameters={
'type': 'object',
'properties': {
'location': {
'type': 'string',
'description': 'City name'
}
},
'required': ['location']
},
func=dummy_weather_func
),
resource_tool.LLMTool(
name='calculate',
human_desc='Perform a calculation',
description='Perform a calculation',
parameters={
'type': 'object',
'properties': {
'expression': {
'type': 'string',
'description': 'Math expression'
}
},
'required': ['expression']
},
func=dummy_calc_func
),
]
return tools
@pytest.mark.asyncio
async def test_generate_tools_for_openai(self, mock_app, sample_tools):
"""Test that generate_tools_for_openai produces correct schema."""
toolmgr = get_toolmgr_module()
manager = toolmgr.ToolManager(mock_app)
result = await manager.generate_tools_for_openai(sample_tools)
assert len(result) == 2
# Verify first tool schema
tool1 = result[0]
assert tool1['type'] == 'function'
assert tool1['function']['name'] == 'get_weather'
assert tool1['function']['description'] == 'Get current weather for a location'
assert 'parameters' in tool1['function']
assert tool1['function']['parameters']['type'] == 'object'
# Verify second tool schema
tool2 = result[1]
assert tool2['type'] == 'function'
assert tool2['function']['name'] == 'calculate'
@pytest.mark.asyncio
async def test_generate_tools_for_anthropic(self, mock_app, sample_tools):
"""Test that generate_tools_for_anthropic produces correct schema."""
toolmgr = get_toolmgr_module()
manager = toolmgr.ToolManager(mock_app)
result = await manager.generate_tools_for_anthropic(sample_tools)
assert len(result) == 2
# Verify first tool schema (Anthropic format)
tool1 = result[0]
assert tool1['name'] == 'get_weather'
assert tool1['description'] == 'Get current weather for a location'
assert 'input_schema' in tool1
assert tool1['input_schema']['type'] == 'object'
# Verify second tool schema
tool2 = result[1]
assert tool2['name'] == 'calculate'
assert 'input_schema' in tool2
@pytest.mark.asyncio
async def test_generate_tools_empty_list(self, mock_app):
"""Test that generating tools from empty list returns empty list."""
toolmgr = get_toolmgr_module()
manager = toolmgr.ToolManager(mock_app)
openai_result = await manager.generate_tools_for_openai([])
assert openai_result == []
anthropic_result = await manager.generate_tools_for_anthropic([])
assert anthropic_result == []
@pytest.mark.asyncio
async def test_openai_schema_fields_complete(self, mock_app, sample_tools):
"""Test that OpenAI schema includes all required fields."""
toolmgr = get_toolmgr_module()
manager = toolmgr.ToolManager(mock_app)
result = await manager.generate_tools_for_openai(sample_tools)
for tool_schema in result:
assert 'type' in tool_schema
assert tool_schema['type'] == 'function'
assert 'function' in tool_schema
func = tool_schema['function']
assert 'name' in func
assert 'description' in func
assert 'parameters' in func
@pytest.mark.asyncio
async def test_anthropic_schema_fields_complete(self, mock_app, sample_tools):
"""Test that Anthropic schema includes all required fields."""
toolmgr = get_toolmgr_module()
manager = toolmgr.ToolManager(mock_app)
result = await manager.generate_tools_for_anthropic(sample_tools)
for tool_schema in result:
assert 'name' in tool_schema
assert 'description' in tool_schema
assert 'input_schema' in tool_schema
class TestToolManagerExecuteFuncCall:
"""Tests for execute_func_call method."""
@pytest.fixture
def mock_app_with_loaders(self):
"""Create mock app with mock tool loaders."""
mock_app = Mock()
mock_app.logger = Mock()
# Create mock plugin loader
mock_plugin_loader = Mock()
mock_plugin_loader.has_tool = AsyncMock(return_value=False)
mock_plugin_loader.invoke_tool = AsyncMock(return_value='plugin_result')
mock_plugin_loader.initialize = AsyncMock()
mock_plugin_loader.shutdown = AsyncMock()
# Create mock MCP loader
mock_mcp_loader = Mock()
mock_mcp_loader.has_tool = AsyncMock(return_value=False)
mock_mcp_loader.invoke_tool = AsyncMock(return_value='mcp_result')
mock_mcp_loader.initialize = AsyncMock()
mock_mcp_loader.shutdown = AsyncMock()
return mock_app, mock_plugin_loader, mock_mcp_loader
@pytest.fixture
def sample_query(self):
"""Create sample query for testing."""
query = Mock(spec=pipeline_query.Query)
return query
@pytest.mark.asyncio
async def test_execute_calls_plugin_loader_when_has_tool(
self, mock_app_with_loaders, sample_query
):
"""Test that execute_func_call uses plugin loader when tool exists there."""
toolmgr = get_toolmgr_module()
mock_app, mock_plugin_loader, mock_mcp_loader = mock_app_with_loaders
mock_plugin_loader.has_tool = AsyncMock(return_value=True)
manager = toolmgr.ToolManager(mock_app)
manager.plugin_tool_loader = mock_plugin_loader
manager.mcp_tool_loader = mock_mcp_loader
result = await manager.execute_func_call(
'test_tool',
{'param': 'value'},
sample_query
)
assert result == 'plugin_result'
mock_plugin_loader.invoke_tool.assert_called_once_with(
'test_tool', {'param': 'value'}, sample_query
)
# MCP loader should not be called
mock_mcp_loader.invoke_tool.assert_not_called()
@pytest.mark.asyncio
async def test_execute_calls_mcp_loader_when_plugin_not_found(
self, mock_app_with_loaders, sample_query
):
"""Test that execute_func_call uses MCP loader when plugin doesn't have tool."""
toolmgr = get_toolmgr_module()
mock_app, mock_plugin_loader, mock_mcp_loader = mock_app_with_loaders
mock_plugin_loader.has_tool = AsyncMock(return_value=False)
mock_mcp_loader.has_tool = AsyncMock(return_value=True)
manager = toolmgr.ToolManager(mock_app)
manager.plugin_tool_loader = mock_plugin_loader
manager.mcp_tool_loader = mock_mcp_loader
result = await manager.execute_func_call(
'test_tool',
{'param': 'value'},
sample_query
)
assert result == 'mcp_result'
mock_mcp_loader.invoke_tool.assert_called_once_with(
'test_tool', {'param': 'value'}, sample_query
)
@pytest.mark.asyncio
async def test_execute_raises_when_tool_not_found(
self, mock_app_with_loaders, sample_query
):
"""Test that execute_func_call raises ValueError when tool not found."""
toolmgr = get_toolmgr_module()
mock_app, mock_plugin_loader, mock_mcp_loader = mock_app_with_loaders
mock_plugin_loader.has_tool = AsyncMock(return_value=False)
mock_mcp_loader.has_tool = AsyncMock(return_value=False)
manager = toolmgr.ToolManager(mock_app)
manager.plugin_tool_loader = mock_plugin_loader
manager.mcp_tool_loader = mock_mcp_loader
with pytest.raises(ValueError, match='未找到工具'):
await manager.execute_func_call(
'unknown_tool',
{},
sample_query
)
@pytest.mark.asyncio
async def test_plugin_loader_checked_first(
self, mock_app_with_loaders, sample_query
):
"""Test that plugin loader is checked before MCP loader."""
toolmgr = get_toolmgr_module()
mock_app, mock_plugin_loader, mock_mcp_loader = mock_app_with_loaders
# Both loaders have the tool, but plugin should be used
mock_plugin_loader.has_tool = AsyncMock(return_value=True)
mock_mcp_loader.has_tool = AsyncMock(return_value=True)
manager = toolmgr.ToolManager(mock_app)
manager.plugin_tool_loader = mock_plugin_loader
manager.mcp_tool_loader = mock_mcp_loader
await manager.execute_func_call('test_tool', {}, sample_query)
# Plugin loader should be invoked, MCP should not
mock_plugin_loader.invoke_tool.assert_called_once()
mock_mcp_loader.invoke_tool.assert_not_called()
class TestToolManagerShutdown:
"""Tests for shutdown method."""
@pytest.mark.asyncio
async def test_shutdown_calls_loader_shutdown(self):
"""Test that shutdown calls shutdown on both loaders."""
toolmgr = get_toolmgr_module()
mock_app = Mock()
mock_plugin_loader = Mock()
mock_plugin_loader.shutdown = AsyncMock()
mock_mcp_loader = Mock()
mock_mcp_loader.shutdown = AsyncMock()
manager = toolmgr.ToolManager(mock_app)
manager.plugin_tool_loader = mock_plugin_loader
manager.mcp_tool_loader = mock_mcp_loader
await manager.shutdown()
mock_plugin_loader.shutdown.assert_called_once()
mock_mcp_loader.shutdown.assert_called_once()
View File
+190
View File
@@ -0,0 +1,190 @@
"""Unit tests for RuntimeKnowledgeBase file storage behavior."""
from __future__ import annotations
import io
import zipfile
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.rag.knowledge.kbmgr import RuntimeKnowledgeBase
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:
@pytest.mark.asyncio
async def test_store_file_creates_pending_record_and_user_task(self):
kb = _make_kb()
def create_user_task(coro, **kwargs):
coro.close()
return SimpleNamespace(id='task-1', kwargs=kwargs)
kb.ap.task_mgr.create_user_task = Mock(side_effect=create_user_task)
task_id = await kb.store_file('documents/test.pdf')
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_store_file_raises_when_source_file_missing(self):
kb = _make_kb()
kb.ap.storage_mgr.storage_provider.exists = AsyncMock(return_value=False)
with pytest.raises(Exception, match='File missing.pdf not found'):
await kb.store_file('missing.pdf')
kb.ap.persistence_mgr.execute_async.assert_not_awaited()
kb.ap.task_mgr.create_user_task.assert_not_called()
class TestStoreZipFile:
@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'])
task_id = await kb._store_zip_file('archive.zip', parser_plugin_id='parser/plugin')
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_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()
with pytest.raises(Exception, match='No supported files found'):
await kb._store_zip_file('archive.zip')
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_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()
with pytest.raises(Exception, match='parser failed'):
await kb._store_file_task(file_obj, task_context)
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:
@pytest.mark.asyncio
async def test_delete_document_returns_false_when_no_plugin_id(self):
kb = _make_kb(plugin_id=None)
result = await kb._delete_document('doc-id')
assert result is False
@pytest.mark.asyncio
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)
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_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'))
result = await kb._delete_document('doc-id')
assert result is False
kb.ap.logger.error.assert_called_once()
@@ -0,0 +1,63 @@
"""Unit tests for RAG i18n name conversion.
Tests cover:
- _to_i18n_name() static method
"""
from __future__ import annotations
from importlib import import_module
def get_kbmgr_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.rag.knowledge.kbmgr')
class TestToI18nName:
"""Tests for _to_i18n_name static method."""
def test_string_input_wrapped(self):
"""Test that string input is wrapped into i18n dict."""
kbmgr = get_kbmgr_module()
result = kbmgr.RAGManager._to_i18n_name('Test Engine')
assert result == {'en_US': 'Test Engine', 'zh_Hans': 'Test Engine'}
def test_dict_input_preserved(self):
"""Test that dict input is returned as-is."""
kbmgr = get_kbmgr_module()
input_dict = {'en_US': 'English Name', 'zh_Hans': '中文名', 'ja_JP': '日本語名'}
result = kbmgr.RAGManager._to_i18n_name(input_dict)
assert result == input_dict
assert result is input_dict # Should return the same object
def test_empty_string_handling(self):
"""Test that empty string is handled correctly."""
kbmgr = get_kbmgr_module()
result = kbmgr.RAGManager._to_i18n_name('')
assert result == {'en_US': '', 'zh_Hans': ''}
def test_none_input_handling(self):
"""Test that None is converted to string 'None'."""
kbmgr = get_kbmgr_module()
result = kbmgr.RAGManager._to_i18n_name(None)
assert result == {'en_US': 'None', 'zh_Hans': 'None'}
def test_number_input_converted_to_string(self):
"""Test that numbers are converted to strings."""
kbmgr = get_kbmgr_module()
result = kbmgr.RAGManager._to_i18n_name(123)
assert result == {'en_US': '123', 'zh_Hans': '123'}
def test_dict_with_partial_keys_preserved(self):
"""Test that dict with only some i18n keys is preserved."""
kbmgr = get_kbmgr_module()
input_dict = {'en_US': 'Only English'}
result = kbmgr.RAGManager._to_i18n_name(input_dict)
assert result == {'en_US': 'Only English'}
def test_dict_with_extra_keys_preserved(self):
"""Test that dict with extra non-i18n keys is preserved."""
kbmgr = get_kbmgr_module()
input_dict = {'en_US': 'English', 'extra_key': 'extra_value'}
result = kbmgr.RAGManager._to_i18n_name(input_dict)
assert result == {'en_US': 'English', 'extra_key': 'extra_value'}
+794
View File
@@ -0,0 +1,794 @@
"""Unit tests for RAG knowledge base manager.
Tests cover:
- RAGManager CRUD operations
- RuntimeKnowledgeBase getters
- Knowledge engine enrichment
- KB loading and removal
"""
from __future__ import annotations
import pytest
import uuid
from unittest.mock import Mock, AsyncMock
from importlib import import_module
def get_rag_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.rag.knowledge.kbmgr')
def create_mock_app():
"""Create mock Application for testing."""
mock_app = Mock()
mock_app.logger = Mock()
mock_app.persistence_mgr = AsyncMock()
mock_app.persistence_mgr.execute_async = AsyncMock()
mock_app.persistence_mgr.serialize_model = Mock(return_value={})
mock_app.plugin_connector = AsyncMock()
mock_app.plugin_connector.is_enable_plugin = True
mock_app.storage_mgr = Mock()
mock_app.storage_mgr.storage_provider = AsyncMock()
mock_app.task_mgr = AsyncMock()
mock_app.task_mgr.create_user_task = Mock(return_value=Mock(id=1))
return mock_app
def create_mock_kb_entity():
"""Create mock KnowledgeBase entity."""
mock_kb = Mock()
mock_kb.uuid = str(uuid.uuid4())
mock_kb.name = 'Test KB'
mock_kb.description = 'Test description'
mock_kb.knowledge_engine_plugin_id = 'author/engine'
mock_kb.collection_id = mock_kb.uuid
mock_kb.creation_settings = {}
mock_kb.retrieval_settings = {}
return mock_kb
class TestRAGManagerCreateKnowledgeBase:
"""Tests for create_knowledge_base method."""
@pytest.mark.asyncio
async def test_creates_kb_with_valid_engine(self):
"""Test creates KB when engine plugin exists."""
rag_module = get_rag_module()
mock_app = create_mock_app()
# Mock valid engine list
mock_app.plugin_connector.list_knowledge_engines = AsyncMock(
return_value=[{'plugin_id': 'author/engine', 'name': 'Engine'}]
)
mock_app.persistence_mgr.execute_async = AsyncMock()
mock_app.plugin_connector.rag_on_kb_create = AsyncMock()
manager = rag_module.RAGManager(mock_app)
kb = await manager.create_knowledge_base(
name='Test KB',
knowledge_engine_plugin_id='author/engine',
creation_settings={'model': 'test'},
)
assert kb.name == 'Test KB'
assert kb.knowledge_engine_plugin_id == 'author/engine'
@pytest.mark.asyncio
async def test_raises_when_engine_not_found(self):
"""Test raises ValueError when engine plugin not found."""
rag_module = get_rag_module()
mock_app = create_mock_app()
# Mock empty engine list
mock_app.plugin_connector.list_knowledge_engines = AsyncMock(return_value=[])
manager = rag_module.RAGManager(mock_app)
with pytest.raises(ValueError) as exc_info:
await manager.create_knowledge_base(
name='Test KB',
knowledge_engine_plugin_id='unknown/engine',
creation_settings={},
)
assert 'not found' in str(exc_info.value)
@pytest.mark.asyncio
async def test_rollback_on_plugin_create_failure(self):
"""Test that DB entry is rolled back when plugin create fails."""
rag_module = get_rag_module()
mock_app = create_mock_app()
mock_app.plugin_connector.list_knowledge_engines = AsyncMock(
return_value=[{'plugin_id': 'author/engine'}]
)
mock_app.persistence_mgr.execute_async = AsyncMock()
mock_app.plugin_connector.rag_on_kb_create = AsyncMock(
side_effect=Exception('Plugin error')
)
manager = rag_module.RAGManager(mock_app)
with pytest.raises(Exception):
await manager.create_knowledge_base(
name='Test KB',
knowledge_engine_plugin_id='author/engine',
creation_settings={},
)
# Should have called delete to rollback
# Check that delete was called (for rollback)
assert len(manager.knowledge_bases) == 0
@pytest.mark.asyncio
async def test_sets_default_retrieval_settings(self):
"""Test that empty retrieval_settings defaults to {}."""
rag_module = get_rag_module()
mock_app = create_mock_app()
mock_app.plugin_connector.list_knowledge_engines = AsyncMock(
return_value=[{'plugin_id': 'author/engine'}]
)
mock_app.persistence_mgr.execute_async = AsyncMock()
mock_app.plugin_connector.rag_on_kb_create = AsyncMock()
manager = rag_module.RAGManager(mock_app)
kb = await manager.create_knowledge_base(
name='Test KB',
knowledge_engine_plugin_id='author/engine',
creation_settings={},
retrieval_settings=None,
)
assert kb.retrieval_settings == {}
@pytest.mark.asyncio
async def test_skips_validation_when_plugin_disabled(self):
"""Test that engine validation is skipped when plugin disabled."""
rag_module = get_rag_module()
mock_app = create_mock_app()
mock_app.plugin_connector.is_enable_plugin = False
mock_app.persistence_mgr.execute_async = AsyncMock()
mock_app.plugin_connector.rag_on_kb_create = AsyncMock()
manager = rag_module.RAGManager(mock_app)
# Should not raise even though engine list would be empty
kb = await manager.create_knowledge_base(
name='Test KB',
knowledge_engine_plugin_id='any/engine',
creation_settings={},
)
assert kb.knowledge_engine_plugin_id == 'any/engine'
class TestRuntimeKnowledgeBaseOnKBCreate:
"""Tests for _on_kb_create method."""
@pytest.mark.asyncio
async def test_calls_plugin_on_create(self):
"""Test that plugin is notified on KB create."""
rag_module = get_rag_module()
mock_app = create_mock_app()
mock_kb = create_mock_kb_entity()
mock_kb.creation_settings = {'model': 'test'}
mock_app.plugin_connector.rag_on_kb_create = AsyncMock()
runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
await runtime_kb._on_kb_create()
mock_app.plugin_connector.rag_on_kb_create.assert_called_once_with(
'author/engine', mock_kb.uuid, {'model': 'test'}
)
@pytest.mark.asyncio
async def test_skips_when_no_plugin_id(self):
"""Test that create notification is skipped when no plugin."""
rag_module = get_rag_module()
mock_app = create_mock_app()
mock_kb = create_mock_kb_entity()
mock_kb.knowledge_engine_plugin_id = None
runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
await runtime_kb._on_kb_create()
mock_app.plugin_connector.rag_on_kb_create.assert_not_called()
@pytest.mark.asyncio
async def test_raises_on_plugin_error(self):
"""Test that exception is raised when plugin fails."""
rag_module = get_rag_module()
mock_app = create_mock_app()
mock_kb = create_mock_kb_entity()
mock_app.plugin_connector.rag_on_kb_create = AsyncMock(
side_effect=Exception('Plugin failed')
)
runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
with pytest.raises(Exception):
await runtime_kb._on_kb_create()
class TestRuntimeKnowledgeBaseDeleteFile:
"""Tests for delete_file method."""
@pytest.mark.asyncio
async def test_delete_file_calls_plugin_and_db(self):
"""Test that delete_file calls plugin and removes DB record."""
rag_module = get_rag_module()
mock_app = create_mock_app()
mock_kb = create_mock_kb_entity()
mock_app.plugin_connector.call_rag_delete_document = AsyncMock(return_value=True)
runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
await runtime_kb.delete_file('file-uuid')
mock_app.plugin_connector.call_rag_delete_document.assert_called_once()
mock_app.persistence_mgr.execute_async.assert_called()
class TestRuntimeKnowledgeBaseIngestDocument:
"""Tests for _ingest_document method."""
@pytest.mark.asyncio
async def test_ingest_calls_plugin(self):
"""Test that ingest calls plugin connector."""
rag_module = get_rag_module()
mock_app = create_mock_app()
mock_kb = create_mock_kb_entity()
mock_app.plugin_connector.call_rag_ingest = AsyncMock(
return_value={'status': 'success'}
)
runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
result = await runtime_kb._ingest_document(
{'filename': 'test.pdf'},
'storage/path',
)
assert result['status'] == 'success'
mock_app.plugin_connector.call_rag_ingest.assert_called_once()
@pytest.mark.asyncio
async def test_ingest_raises_when_no_plugin_id(self):
"""Test that ValueError is raised when no plugin ID."""
rag_module = get_rag_module()
mock_app = create_mock_app()
mock_kb = create_mock_kb_entity()
mock_kb.knowledge_engine_plugin_id = None
runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
with pytest.raises(ValueError) as exc_info:
await runtime_kb._ingest_document({'filename': 'test.pdf'}, 'path')
assert 'Plugin ID required' in str(exc_info.value)
class TestRAGManagerLoadKnowledgeBasesFromDB:
"""Tests for load_knowledge_bases_from_db method."""
@pytest.mark.asyncio
async def test_loads_all_kbs_from_db(self):
"""Test that all KBs are loaded from database."""
rag_module = get_rag_module()
mock_app = create_mock_app()
mock_kb1 = create_mock_kb_entity()
mock_kb2 = create_mock_kb_entity()
mock_app.persistence_mgr.execute_async = AsyncMock(
return_value=Mock(all=Mock(return_value=[mock_kb1, mock_kb2]))
)
manager = rag_module.RAGManager(mock_app)
await manager.load_knowledge_bases_from_db()
assert len(manager.knowledge_bases) == 2
@pytest.mark.asyncio
async def test_handles_load_error_gracefully(self):
"""Test that load errors are logged but not raised."""
rag_module = get_rag_module()
mock_app = create_mock_app()
# KB that will cause initialize to fail
mock_kb = create_mock_kb_entity()
mock_app.persistence_mgr.execute_async = AsyncMock(
return_value=Mock(all=Mock(return_value=[mock_kb]))
)
# Make initialize fail by having plugin_connector throw error
mock_app.plugin_connector.rag_on_kb_create = AsyncMock(
side_effect=Exception('Init failed')
)
manager = rag_module.RAGManager(mock_app)
# Should not raise - errors are caught
await manager.load_knowledge_bases_from_db()
# KB should still be loaded (initialize just passes)
# The error would come from runtime_kb.initialize which we can't easily mock
# So we just verify it doesn't crash
class TestRuntimeKnowledgeBaseGetters:
"""Tests for RuntimeKnowledgeBase getter methods."""
def test_get_uuid_returns_entity_uuid(self):
"""Test get_uuid returns KB entity UUID."""
rag_module = get_rag_module()
mock_app = create_mock_app()
mock_kb = create_mock_kb_entity()
runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
assert runtime_kb.get_uuid() == mock_kb.uuid
def test_get_name_returns_entity_name(self):
"""Test get_name returns KB entity name."""
rag_module = get_rag_module()
mock_app = create_mock_app()
mock_kb = create_mock_kb_entity()
runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
assert runtime_kb.get_name() == mock_kb.name
def test_get_knowledge_engine_plugin_id_returns_plugin_id(self):
"""Test get_knowledge_engine_plugin_id returns plugin ID."""
rag_module = get_rag_module()
mock_app = create_mock_app()
mock_kb = create_mock_kb_entity()
runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
assert runtime_kb.get_knowledge_engine_plugin_id() == 'author/engine'
def test_get_knowledge_engine_plugin_id_returns_empty_when_none(self):
"""Test returns empty string when plugin_id is None."""
rag_module = get_rag_module()
mock_app = create_mock_app()
mock_kb = create_mock_kb_entity()
mock_kb.knowledge_engine_plugin_id = None
runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
assert runtime_kb.get_knowledge_engine_plugin_id() == ''
class TestRuntimeKnowledgeBaseRetrieve:
"""Tests for RuntimeKnowledgeBase retrieve method."""
@pytest.mark.asyncio
async def test_retrieve_merges_settings(self):
"""Test that retrieve merges stored and request settings."""
rag_module = get_rag_module()
mock_app = create_mock_app()
mock_kb = create_mock_kb_entity()
mock_kb.retrieval_settings = {'top_k': 10, 'model': 'default'}
# Mock plugin connector response with valid RetrievalResultEntry fields
# content must be list of ContentElement dicts
mock_app.plugin_connector.call_rag_retrieve = AsyncMock(
return_value={
'results': [
{
'id': 'doc1',
'content': [{'type': 'text', 'text': 'test content'}],
'metadata': {},
'distance': 0.1,
}
]
}
)
runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
# Override top_k in request
results = await runtime_kb.retrieve('query text', settings={'top_k': 20})
assert len(results) == 1
# Check that merged settings were passed (top_k overridden)
call_args = mock_app.plugin_connector.call_rag_retrieve.call_args
assert call_args[0][1]['retrieval_settings']['top_k'] == 20
@pytest.mark.asyncio
async def test_retrieve_adds_default_top_k(self):
"""Test that default top_k=5 is added when not specified."""
rag_module = get_rag_module()
mock_app = create_mock_app()
mock_kb = create_mock_kb_entity()
mock_kb.retrieval_settings = {}
mock_app.plugin_connector.call_rag_retrieve = AsyncMock(
return_value={'results': []}
)
runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
await runtime_kb.retrieve('query text')
call_args = mock_app.plugin_connector.call_rag_retrieve.call_args
assert call_args[0][1]['retrieval_settings']['top_k'] == 5
@pytest.mark.asyncio
async def test_retrieve_converts_dict_to_entry(self):
"""Test that dict results are converted to RetrievalResultEntry."""
rag_module = get_rag_module()
mock_app = create_mock_app()
mock_kb = create_mock_kb_entity()
# Mock response with valid RetrievalResultEntry fields
# content must be list of ContentElement dicts
mock_app.plugin_connector.call_rag_retrieve = AsyncMock(
return_value={
'results': [
{
'id': 'doc1',
'content': [{'type': 'text', 'text': 'test content'}],
'metadata': {'source': 'file.pdf'},
'distance': 0.15,
}
]
}
)
runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
results = await runtime_kb.retrieve('query')
assert len(results) == 1
# Result should be RetrievalResultEntry
assert hasattr(results[0], 'content')
assert results[0].id == 'doc1'
class TestRuntimeKnowledgeBaseDispose:
"""Tests for RuntimeKnowledgeBase dispose method."""
@pytest.mark.asyncio
async def test_dispose_calls_on_kb_delete(self):
"""Test that dispose calls _on_kb_delete."""
rag_module = get_rag_module()
mock_app = create_mock_app()
mock_kb = create_mock_kb_entity()
mock_app.plugin_connector.rag_on_kb_delete = AsyncMock()
runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
await runtime_kb.dispose()
mock_app.plugin_connector.rag_on_kb_delete.assert_called_once()
@pytest.mark.asyncio
async def test_dispose_skips_when_no_plugin_id(self):
"""Test that dispose skips when no plugin ID."""
rag_module = get_rag_module()
mock_app = create_mock_app()
mock_kb = create_mock_kb_entity()
mock_kb.knowledge_engine_plugin_id = None
runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
await runtime_kb.dispose()
# Should not call plugin connector
mock_app.plugin_connector.rag_on_kb_delete.assert_not_called()
class TestRAGManagerInit:
"""Tests for RAGManager initialization."""
def test_init_stores_app_reference(self):
"""Test that __init__ stores Application reference."""
rag_module = get_rag_module()
mock_app = create_mock_app()
manager = rag_module.RAGManager(mock_app)
assert manager.ap is mock_app
def test_init_creates_empty_knowledge_bases_dict(self):
"""Test that knowledge_bases starts as empty dict."""
rag_module = get_rag_module()
mock_app = create_mock_app()
manager = rag_module.RAGManager(mock_app)
assert manager.knowledge_bases == {}
class TestRAGManagerGetKnowledgeBase:
"""Tests for RAGManager get methods."""
@pytest.mark.asyncio
async def test_get_knowledge_base_by_uuid_returns_runtime_kb(self):
"""Test get_knowledge_base_by_uuid returns loaded KB."""
rag_module = get_rag_module()
mock_app = create_mock_app()
manager = rag_module.RAGManager(mock_app)
mock_kb = create_mock_kb_entity()
# Manually add to knowledge_bases
runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
manager.knowledge_bases[mock_kb.uuid] = runtime_kb
result = await manager.get_knowledge_base_by_uuid(mock_kb.uuid)
assert result is runtime_kb
@pytest.mark.asyncio
async def test_get_knowledge_base_by_uuid_returns_none_when_not_found(self):
"""Test returns None when KB not in runtime."""
rag_module = get_rag_module()
mock_app = create_mock_app()
manager = rag_module.RAGManager(mock_app)
result = await manager.get_knowledge_base_by_uuid('nonexistent-uuid')
assert result is None
@pytest.mark.asyncio
async def test_remove_knowledge_base_from_runtime(self):
"""Test remove_knowledge_base_from_runtime removes KB."""
rag_module = get_rag_module()
mock_app = create_mock_app()
manager = rag_module.RAGManager(mock_app)
mock_kb = create_mock_kb_entity()
# Add to knowledge_bases
runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
manager.knowledge_bases[mock_kb.uuid] = runtime_kb
await manager.remove_knowledge_base_from_runtime(mock_kb.uuid)
assert mock_kb.uuid not in manager.knowledge_bases
class TestRAGManagerEnrichKB:
"""Tests for _enrich_kb_dict method."""
def test_enrich_adds_engine_info_from_map(self):
"""Test that engine info is added from engine_map."""
rag_module = get_rag_module()
mock_app = create_mock_app()
manager = rag_module.RAGManager(mock_app)
kb_dict = {'knowledge_engine_plugin_id': 'author/engine'}
engine_map = {
'author/engine': {
'plugin_id': 'author/engine',
'name': 'Test Engine',
'capabilities': ['doc_ingestion', 'search'],
}
}
manager._enrich_kb_dict(kb_dict, engine_map)
assert 'knowledge_engine' in kb_dict
assert kb_dict['knowledge_engine']['plugin_id'] == 'author/engine'
assert kb_dict['knowledge_engine']['capabilities'] == ['doc_ingestion', 'search']
def test_enrich_uses_fallback_when_engine_not_in_map(self):
"""Test that fallback info is used when engine not found."""
rag_module = get_rag_module()
mock_app = create_mock_app()
manager = rag_module.RAGManager(mock_app)
kb_dict = {'knowledge_engine_plugin_id': 'unknown/engine'}
engine_map = {}
manager._enrich_kb_dict(kb_dict, engine_map)
assert 'knowledge_engine' in kb_dict
assert kb_dict['knowledge_engine']['plugin_id'] == 'unknown/engine'
assert kb_dict['knowledge_engine']['capabilities'] == []
def test_enrich_uses_fallback_when_no_plugin_id(self):
"""Test that fallback is used when no plugin ID."""
rag_module = get_rag_module()
mock_app = create_mock_app()
manager = rag_module.RAGManager(mock_app)
kb_dict = {}
engine_map = {}
manager._enrich_kb_dict(kb_dict, engine_map)
assert 'knowledge_engine' in kb_dict
# Should have Internal (Legacy) name
assert 'en_US' in kb_dict['knowledge_engine']['name']
def test_enrich_converts_string_name_to_i18n(self):
"""Test that engine name is converted to i18n dict."""
rag_module = get_rag_module()
mock_app = create_mock_app()
manager = rag_module.RAGManager(mock_app)
kb_dict = {'knowledge_engine_plugin_id': 'author/engine'}
engine_map = {
'author/engine': {
'plugin_id': 'author/engine',
'name': 'Simple Name', # String, not dict
'capabilities': [],
}
}
manager._enrich_kb_dict(kb_dict, engine_map)
# Name should be converted to i18n dict
engine_name = kb_dict['knowledge_engine']['name']
assert isinstance(engine_name, dict)
assert engine_name['en_US'] == 'Simple Name'
class TestRAGManagerDeleteKnowledgeBase:
"""Tests for delete_knowledge_base method."""
@pytest.mark.asyncio
async def test_delete_removes_from_runtime_and_disposes(self):
"""Test that delete removes KB and calls dispose."""
rag_module = get_rag_module()
mock_app = create_mock_app()
manager = rag_module.RAGManager(mock_app)
mock_kb = create_mock_kb_entity()
# Add to knowledge_bases
runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
manager.knowledge_bases[mock_kb.uuid] = runtime_kb
await manager.delete_knowledge_base(mock_kb.uuid)
assert mock_kb.uuid not in manager.knowledge_bases
@pytest.mark.asyncio
async def test_delete_logs_warning_when_not_in_runtime(self):
"""Test that warning is logged when KB not in runtime."""
rag_module = get_rag_module()
mock_app = create_mock_app()
manager = rag_module.RAGManager(mock_app)
await manager.delete_knowledge_base('nonexistent-uuid')
mock_app.logger.warning.assert_called_once()
class TestRAGManagerGetAllDetails:
"""Tests for get_all_knowledge_base_details method."""
@pytest.mark.asyncio
async def test_returns_empty_list_when_no_kbs(self):
"""Test returns empty list when no knowledge bases."""
rag_module = get_rag_module()
mock_app = create_mock_app()
mock_app.persistence_mgr.execute_async = AsyncMock(
return_value=Mock(all=Mock(return_value=[]))
)
manager = rag_module.RAGManager(mock_app)
result = await manager.get_all_knowledge_base_details()
assert result == []
@pytest.mark.asyncio
async def test_enriches_each_kb_with_engine_info(self):
"""Test that each KB is enriched with engine info."""
rag_module = get_rag_module()
mock_app = create_mock_app()
# Mock DB result
mock_kb_row = Mock()
mock_app.persistence_mgr.execute_async = AsyncMock(
return_value=Mock(all=Mock(return_value=[mock_kb_row]))
)
mock_app.persistence_mgr.serialize_model = Mock(
return_value={'uuid': 'kb1', 'knowledge_engine_plugin_id': 'author/engine'}
)
mock_app.plugin_connector.list_knowledge_engines = AsyncMock(
return_value=[{'plugin_id': 'author/engine', 'name': 'Engine', 'capabilities': ['search']}]
)
manager = rag_module.RAGManager(mock_app)
result = await manager.get_all_knowledge_base_details()
assert len(result) == 1
assert 'knowledge_engine' in result[0]
class TestRAGManagerGetDetails:
"""Tests for get_knowledge_base_details method."""
@pytest.mark.asyncio
async def test_returns_none_when_kb_not_found(self):
"""Test returns None when KB doesn't exist."""
rag_module = get_rag_module()
mock_app = create_mock_app()
mock_app.persistence_mgr.execute_async = AsyncMock(
return_value=Mock(first=Mock(return_value=None))
)
manager = rag_module.RAGManager(mock_app)
result = await manager.get_knowledge_base_details('nonexistent')
assert result is None
@pytest.mark.asyncio
async def test_returns_enriched_kb_dict(self):
"""Test returns enriched KB dict when found."""
rag_module = get_rag_module()
mock_app = create_mock_app()
mock_kb_row = Mock()
mock_app.persistence_mgr.execute_async = AsyncMock(
return_value=Mock(first=Mock(return_value=mock_kb_row))
)
mock_app.persistence_mgr.serialize_model = Mock(
return_value={'uuid': 'kb1', 'knowledge_engine_plugin_id': 'author/engine'}
)
mock_app.plugin_connector.list_knowledge_engines = AsyncMock(
return_value=[{'plugin_id': 'author/engine', 'name': 'Engine', 'capabilities': []}]
)
manager = rag_module.RAGManager(mock_app)
result = await manager.get_knowledge_base_details('kb1')
assert result is not None
assert 'knowledge_engine' in result
class TestRAGManagerLoadKnowledgeBase:
"""Tests for load_knowledge_base method."""
@pytest.mark.asyncio
async def test_loads_kb_entity_into_runtime(self):
"""Test that KB entity is loaded into runtime."""
rag_module = get_rag_module()
mock_app = create_mock_app()
manager = rag_module.RAGManager(mock_app)
mock_kb = create_mock_kb_entity()
result = await manager.load_knowledge_base(mock_kb)
assert mock_kb.uuid in manager.knowledge_bases
assert result.get_uuid() == mock_kb.uuid
@pytest.mark.asyncio
async def test_load_handles_dict_entity(self):
"""Test that dict entity is converted to KB object."""
rag_module = get_rag_module()
mock_app = create_mock_app()
manager = rag_module.RAGManager(mock_app)
kb_dict = {
'uuid': 'kb-uuid',
'name': 'Test',
'knowledge_engine_plugin_id': 'author/engine',
'knowledge_engine': {'name': 'should_be_filtered'}, # non-db field
}
await manager.load_knowledge_base(kb_dict)
assert 'kb-uuid' in manager.knowledge_bases
@@ -0,0 +1,522 @@
"""Tests for RAGRuntimeService.
Tests the service that handles RAG-related requests from plugins,
using mocked vector_db_mgr and storage_mgr.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from tests.utils.import_isolation import isolated_sys_modules
class TestRAGRuntimeServiceVectorUpsert:
"""Tests for vector_upsert method."""
def _create_mock_app(self):
"""Create mock app with vector_db_mgr and storage_mgr."""
mock_app = MagicMock()
mock_app.vector_db_mgr = MagicMock()
mock_app.vector_db_mgr.upsert = AsyncMock()
mock_app.storage_mgr = MagicMock()
mock_app.storage_mgr.storage_provider = MagicMock()
mock_app.storage_mgr.storage_provider.load = AsyncMock(return_value=b'content')
return mock_app
def _make_rag_import_mocks(self):
"""Create mocks needed for importing RAG service."""
return {
'langbot.pkg.core.app': MagicMock(),
'langbot_plugin.api.entities.builtin.rag': MagicMock(),
}
@pytest.mark.asyncio
async def test_vector_upsert_basic(self):
"""Basic vector upsert delegates to vector_db_mgr."""
mock_app = self._create_mock_app()
mocks = self._make_rag_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.rag.service.runtime import RAGRuntimeService
service = RAGRuntimeService(mock_app)
vectors = [[0.1, 0.2], [0.3, 0.4]]
ids = ['id1', 'id2']
await service.vector_upsert(
collection_id='test_collection',
vectors=vectors,
ids=ids,
)
mock_app.vector_db_mgr.upsert.assert_called_once()
call_args = mock_app.vector_db_mgr.upsert.call_args
assert call_args.kwargs['collection_name'] == 'test_collection'
assert call_args.kwargs['vectors'] == vectors
assert call_args.kwargs['ids'] == ids
# Default metadata is empty dicts
assert call_args.kwargs['metadata'] == [{} for _ in vectors]
@pytest.mark.asyncio
async def test_vector_upsert_with_metadata(self):
"""Vector upsert with provided metadata."""
mock_app = self._create_mock_app()
mocks = self._make_rag_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.rag.service.runtime import RAGRuntimeService
service = RAGRuntimeService(mock_app)
vectors = [[0.1, 0.2]]
ids = ['id1']
metadata = [{'file_id': 'abc', 'page': 1}]
await service.vector_upsert(
collection_id='test',
vectors=vectors,
ids=ids,
metadata=metadata,
)
call_args = mock_app.vector_db_mgr.upsert.call_args
assert call_args.kwargs['metadata'] == metadata
@pytest.mark.asyncio
async def test_vector_upsert_with_documents(self):
"""Vector upsert with documents for full-text search."""
mock_app = self._create_mock_app()
mocks = self._make_rag_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.rag.service.runtime import RAGRuntimeService
service = RAGRuntimeService(mock_app)
vectors = [[0.1, 0.2]]
ids = ['id1']
documents = ['This is a test document']
await service.vector_upsert(
collection_id='test',
vectors=vectors,
ids=ids,
documents=documents,
)
call_args = mock_app.vector_db_mgr.upsert.call_args
assert call_args.kwargs['documents'] == documents
class TestRAGRuntimeServiceVectorSearch:
"""Tests for vector_search method."""
def _create_mock_app(self):
"""Create mock app."""
mock_app = MagicMock()
mock_app.vector_db_mgr = MagicMock()
mock_app.vector_db_mgr.search = AsyncMock(return_value=[
{'id': 'id1', 'distance': 0.1, 'metadata': {'file_id': 'abc'}},
{'id': 'id2', 'distance': 0.2, 'metadata': {'file_id': 'def'}},
])
return mock_app
def _make_rag_import_mocks(self):
return {
'langbot.pkg.core.app': MagicMock(),
'langbot_plugin.api.entities.builtin.rag': MagicMock(),
}
@pytest.mark.asyncio
async def test_vector_search_basic(self):
"""Basic vector search delegates to vector_db_mgr."""
mock_app = self._create_mock_app()
mocks = self._make_rag_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.rag.service.runtime import RAGRuntimeService
service = RAGRuntimeService(mock_app)
query_vector = [0.1, 0.2, 0.3]
result = await service.vector_search(
collection_id='test',
query_vector=query_vector,
top_k=5,
)
assert len(result) == 2
mock_app.vector_db_mgr.search.assert_called_once()
call_args = mock_app.vector_db_mgr.search.call_args
assert call_args.kwargs['collection_name'] == 'test'
assert call_args.kwargs['query_vector'] == query_vector
assert call_args.kwargs['limit'] == 5
@pytest.mark.asyncio
async def test_vector_search_with_filters(self):
"""Vector search with metadata filters."""
mock_app = self._create_mock_app()
mocks = self._make_rag_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.rag.service.runtime import RAGRuntimeService
service = RAGRuntimeService(mock_app)
filters = {'file_id': 'abc'}
await service.vector_search(
collection_id='test',
query_vector=[0.1, 0.2],
top_k=10,
filters=filters,
)
call_args = mock_app.vector_db_mgr.search.call_args
assert call_args.kwargs['filter'] == filters
@pytest.mark.asyncio
async def test_vector_search_hybrid_mode(self):
"""Vector search with hybrid search type."""
mock_app = self._create_mock_app()
mocks = self._make_rag_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.rag.service.runtime import RAGRuntimeService
service = RAGRuntimeService(mock_app)
await service.vector_search(
collection_id='test',
query_vector=[0.1, 0.2],
top_k=10,
search_type='hybrid',
query_text='search query',
vector_weight=0.7,
)
call_args = mock_app.vector_db_mgr.search.call_args
assert call_args.kwargs['search_type'] == 'hybrid'
assert call_args.kwargs['query_text'] == 'search query'
assert call_args.kwargs['vector_weight'] == 0.7
class TestRAGRuntimeServiceVectorDelete:
"""Tests for vector_delete method."""
def _create_mock_app(self):
mock_app = MagicMock()
mock_app.vector_db_mgr = MagicMock()
mock_app.vector_db_mgr.delete_by_file_id = AsyncMock()
mock_app.vector_db_mgr.delete_by_filter = AsyncMock(return_value=5)
return mock_app
def _make_rag_import_mocks(self):
return {
'langbot.pkg.core.app': MagicMock(),
'langbot_plugin.api.entities.builtin.rag': MagicMock(),
}
@pytest.mark.asyncio
async def test_vector_delete_by_file_ids(self):
"""Delete by file_ids delegates to delete_by_file_id."""
mock_app = self._create_mock_app()
mocks = self._make_rag_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.rag.service.runtime import RAGRuntimeService
service = RAGRuntimeService(mock_app)
result = await service.vector_delete(
collection_id='test',
file_ids=['file1', 'file2', 'file3'],
)
assert result == 3 # Returns count of file_ids
mock_app.vector_db_mgr.delete_by_file_id.assert_called_once()
call_args = mock_app.vector_db_mgr.delete_by_file_id.call_args
assert call_args.kwargs['collection_name'] == 'test'
assert call_args.kwargs['file_ids'] == ['file1', 'file2', 'file3']
@pytest.mark.asyncio
async def test_vector_delete_by_filters(self):
"""Delete by filters delegates to delete_by_filter."""
mock_app = self._create_mock_app()
mocks = self._make_rag_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.rag.service.runtime import RAGRuntimeService
service = RAGRuntimeService(mock_app)
filters = {'status': 'deleted'}
result = await service.vector_delete(
collection_id='test',
filters=filters,
)
assert result == 5 # Returns count from delete_by_filter
mock_app.vector_db_mgr.delete_by_filter.assert_called_once()
call_args = mock_app.vector_db_mgr.delete_by_filter.call_args
assert call_args.kwargs['collection_name'] == 'test'
assert call_args.kwargs['filter'] == filters
@pytest.mark.asyncio
async def test_vector_delete_no_params(self):
"""Delete with no params returns 0."""
mock_app = self._create_mock_app()
mocks = self._make_rag_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.rag.service.runtime import RAGRuntimeService
service = RAGRuntimeService(mock_app)
result = await service.vector_delete(collection_id='test')
assert result == 0
mock_app.vector_db_mgr.delete_by_file_id.assert_not_called()
mock_app.vector_db_mgr.delete_by_filter.assert_not_called()
class TestRAGRuntimeServiceVectorList:
"""Tests for vector_list method."""
def _create_mock_app(self):
mock_app = MagicMock()
mock_app.vector_db_mgr = MagicMock()
mock_app.vector_db_mgr.list_by_filter = AsyncMock(
return_value=(
[{'id': 'id1', 'metadata': {'file_id': 'abc'}}],
10
)
)
return mock_app
def _make_rag_import_mocks(self):
return {
'langbot.pkg.core.app': MagicMock(),
'langbot_plugin.api.entities.builtin.rag': MagicMock(),
}
@pytest.mark.asyncio
async def test_vector_list_basic(self):
"""Basic vector list delegates to vector_db_mgr."""
mock_app = self._create_mock_app()
mocks = self._make_rag_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.rag.service.runtime import RAGRuntimeService
service = RAGRuntimeService(mock_app)
items, total = await service.vector_list(
collection_id='test',
)
assert len(items) == 1
assert total == 10
mock_app.vector_db_mgr.list_by_filter.assert_called_once()
call_args = mock_app.vector_db_mgr.list_by_filter.call_args
assert call_args.kwargs['collection_name'] == 'test'
assert call_args.kwargs['limit'] == 20 # Default
assert call_args.kwargs['offset'] == 0 # Default
@pytest.mark.asyncio
async def test_vector_list_with_pagination(self):
"""Vector list with custom pagination."""
mock_app = self._create_mock_app()
mocks = self._make_rag_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.rag.service.runtime import RAGRuntimeService
service = RAGRuntimeService(mock_app)
await service.vector_list(
collection_id='test',
limit=50,
offset=100,
)
call_args = mock_app.vector_db_mgr.list_by_filter.call_args
assert call_args.kwargs['limit'] == 50
assert call_args.kwargs['offset'] == 100
@pytest.mark.asyncio
async def test_vector_list_with_filters(self):
"""Vector list with metadata filters."""
mock_app = self._create_mock_app()
mocks = self._make_rag_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.rag.service.runtime import RAGRuntimeService
service = RAGRuntimeService(mock_app)
filters = {'file_id': 'abc'}
await service.vector_list(
collection_id='test',
filters=filters,
)
call_args = mock_app.vector_db_mgr.list_by_filter.call_args
assert call_args.kwargs['filter'] == filters
class TestRAGRuntimeServiceGetFileStream:
"""Tests for get_file_stream method."""
def _create_mock_app(self):
mock_app = MagicMock()
mock_app.vector_db_mgr = MagicMock()
mock_app.storage_mgr = MagicMock()
mock_app.storage_mgr.storage_provider = MagicMock()
mock_app.storage_mgr.storage_provider.load = AsyncMock(return_value=b'file content')
return mock_app
def _make_rag_import_mocks(self):
return {
'langbot.pkg.core.app': MagicMock(),
'langbot_plugin.api.entities.builtin.rag': MagicMock(),
}
@pytest.mark.asyncio
async def test_get_file_stream_basic(self):
"""Get file stream loads from storage."""
mock_app = self._create_mock_app()
mocks = self._make_rag_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.rag.service.runtime import RAGRuntimeService
service = RAGRuntimeService(mock_app)
result = await service.get_file_stream('knowledge/files/doc.pdf')
assert result == b'file content'
mock_app.storage_mgr.storage_provider.load.assert_called_once_with('knowledge/files/doc.pdf')
@pytest.mark.asyncio
async def test_get_file_stream_empty_result(self):
"""Empty file returns empty bytes."""
mock_app = self._create_mock_app()
mock_app.storage_mgr.storage_provider.load = AsyncMock(return_value=None)
mocks = self._make_rag_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.rag.service.runtime import RAGRuntimeService
service = RAGRuntimeService(mock_app)
result = await service.get_file_stream('nonexistent.pdf')
assert result == b''
@pytest.mark.asyncio
async def test_get_file_stream_normalizes_safe_path(self):
"""Safe relative paths are normalized before loading."""
mock_app = self._create_mock_app()
mocks = self._make_rag_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.rag.service.runtime import RAGRuntimeService
service = RAGRuntimeService(mock_app)
result = await service.get_file_stream('knowledge/./files/doc.pdf')
assert result == b'file content'
mock_app.storage_mgr.storage_provider.load.assert_called_once_with('knowledge/files/doc.pdf')
@pytest.mark.asyncio
async def test_get_file_stream_path_traversal_blocked(self):
"""Path traversal attacks are blocked."""
mock_app = self._create_mock_app()
mocks = self._make_rag_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.rag.service.runtime import RAGRuntimeService
service = RAGRuntimeService(mock_app)
# Absolute path should raise ValueError
with pytest.raises(ValueError, match='Invalid storage path'):
await service.get_file_stream('/etc/passwd')
# Path traversal should raise ValueError
with pytest.raises(ValueError, match='Invalid storage path'):
await service.get_file_stream('knowledge/../../../etc/passwd')
@pytest.mark.asyncio
@pytest.mark.parametrize(
'storage_path',
[
'',
'../secret.txt',
'/absolute/path.txt',
'..\\secret.txt',
'nested\\..\\secret.txt',
'%2e%2e/secret.txt',
'nested/%2e%2e/secret.txt',
'C:\\secret.txt',
'safe/\x00file.txt',
],
)
async def test_get_file_stream_rejects_unsafe_paths(self, storage_path: str):
"""Unsafe runtime file paths are rejected before storage load."""
mock_app = self._create_mock_app()
mocks = self._make_rag_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.rag.service.runtime import RAGRuntimeService
service = RAGRuntimeService(mock_app)
with pytest.raises(ValueError, match='Invalid storage path'):
await service.get_file_stream(storage_path)
mock_app.storage_mgr.storage_provider.load.assert_not_called()
@pytest.mark.asyncio
async def test_get_file_stream_normalizes_path(self):
"""Valid paths with .. in filename (not traversal) should work."""
mock_app = self._create_mock_app()
mocks = self._make_rag_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.rag.service.runtime import RAGRuntimeService
service = RAGRuntimeService(mock_app)
# Path that contains '..' as part of filename (not traversal)
# This should NOT raise - posixpath.normpath handles this
# But the current implementation checks '..' in split('/')
# Let's test a simple valid path
await service.get_file_stream('knowledge/files/test.pdf')
mock_app.storage_mgr.storage_provider.load.assert_called()
@@ -176,6 +176,38 @@ class TestPathTraversalPrevention:
assert loaded == content
await provider.delete(key)
@pytest.mark.asyncio
async def test_delete_dir_recursive_non_existing_dir(self, storage_provider):
"""delete_dir_recursive should handle non-existing directories gracefully."""
provider, storage_path = storage_provider
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
# Try to delete a non-existing directory - should not raise
await provider.delete_dir_recursive("nonexistent_dir")
@pytest.mark.asyncio
async def test_delete_dir_recursive_with_files(self, storage_provider):
"""delete_dir_recursive should delete directory with files inside."""
provider, storage_path = storage_provider
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
# Create a directory with files
key1 = "test_dir/file1.txt"
key2 = "test_dir/file2.txt"
await provider.save(key1, b"content1")
await provider.save(key2, b"content2")
# Verify files exist
assert await provider.exists(key1)
assert await provider.exists(key2)
# Delete directory recursively
await provider.delete_dir_recursive("test_dir")
# Verify files no longer exist
assert not await provider.exists(key1)
assert not await provider.exists(key2)
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+328
View File
@@ -0,0 +1,328 @@
"""Unit tests for S3StorageProvider.
Tests cover:
- S3 client initialization with bucket creation
- CRUD operations (save, load, exists, delete, size)
- Recursive directory deletion
- Error handling for various S3 errors
Uses moto library to mock AWS S3 service.
"""
from __future__ import annotations
import pytest
from unittest.mock import Mock
from importlib import import_module
def get_s3storage_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.storage.providers.s3storage')
@pytest.fixture
def mock_app_with_s3_config():
"""Create mock app with S3 configuration."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {
'storage': {
's3': {
'endpoint_url': '',
'access_key_id': 'testing',
'secret_access_key': 'testing',
'region': 'us-east-1',
'bucket': 'test-langbot-storage',
}
}
}
mock_app.logger = Mock()
return mock_app
@pytest.fixture
def s3_mock():
"""Set up moto S3 mock context."""
from moto import mock_aws
with mock_aws():
import boto3
# Create bucket for tests that need pre-existing bucket
s3 = boto3.client('s3', region_name='us-east-1')
yield s3
class TestS3StorageProviderInit:
"""Tests for S3StorageProvider initialization."""
def test_init_stores_app_reference(self):
"""Test that __init__ stores the Application reference."""
s3storage = get_s3storage_module()
mock_app = Mock()
provider = s3storage.S3StorageProvider(mock_app)
assert provider.ap is mock_app
def test_init_s3_client_none(self):
"""Test that s3_client starts as None."""
s3storage = get_s3storage_module()
mock_app = Mock()
provider = s3storage.S3StorageProvider(mock_app)
assert provider.s3_client is None
assert provider.bucket_name is None
class TestS3StorageProviderWithMoto:
"""Tests using moto to mock AWS S3."""
@pytest.mark.asyncio
async def test_initialize_creates_bucket_when_not_exists(self, mock_app_with_s3_config, s3_mock):
"""Test that initialize creates bucket when it doesn't exist."""
s3storage = get_s3storage_module()
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
assert provider.s3_client is not None
assert provider.bucket_name == 'test-langbot-storage'
mock_app_with_s3_config.logger.info.assert_called()
@pytest.mark.asyncio
async def test_initialize_uses_existing_bucket(self, mock_app_with_s3_config, s3_mock):
"""Test that initialize uses existing bucket without creating."""
s3storage = get_s3storage_module()
# Pre-create bucket in mock
s3_mock.create_bucket(Bucket='test-langbot-storage')
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
assert provider.s3_client is not None
# Bucket creation log should not be called since bucket exists
# Note: moto may still call head_bucket successfully
@pytest.mark.asyncio
async def test_save_and_load_bytes(self, mock_app_with_s3_config, s3_mock):
"""Test that save and load work correctly."""
s3storage = get_s3storage_module()
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
# Save data
test_data = b'Hello, S3!'
await provider.save('test/file.txt', test_data)
# Load data
loaded_data = await provider.load('test/file.txt')
assert loaded_data == test_data
@pytest.mark.asyncio
async def test_exists_returns_true_for_existing_object(self, mock_app_with_s3_config, s3_mock):
"""Test that exists returns True for existing object."""
s3storage = get_s3storage_module()
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
# Save data
await provider.save('test/file.txt', b'data')
# Check existence
result = await provider.exists('test/file.txt')
assert result is True
@pytest.mark.asyncio
async def test_exists_returns_false_for_nonexistent_object(self, mock_app_with_s3_config, s3_mock):
"""Test that exists returns False for nonexistent object."""
s3storage = get_s3storage_module()
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
# Check existence without saving
result = await provider.exists('nonexistent/file.txt')
assert result is False
@pytest.mark.asyncio
async def test_delete_removes_object(self, mock_app_with_s3_config, s3_mock):
"""Test that delete removes object."""
s3storage = get_s3storage_module()
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
# Save data
await provider.save('test/file.txt', b'data')
# Delete
await provider.delete('test/file.txt')
# Check existence
result = await provider.exists('test/file.txt')
assert result is False
@pytest.mark.asyncio
async def test_size_returns_content_length(self, mock_app_with_s3_config, s3_mock):
"""Test that size returns correct content length."""
s3storage = get_s3storage_module()
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
# Save data
test_data = b'12345' # 5 bytes
await provider.save('test/file.txt', test_data)
# Get size
size = await provider.size('test/file.txt')
assert size == 5
@pytest.mark.asyncio
async def test_delete_dir_recursive_removes_all_objects(self, mock_app_with_s3_config, s3_mock):
"""Test that delete_dir_recursive removes all objects with prefix."""
s3storage = get_s3storage_module()
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
# Save multiple objects in directory
await provider.save('testdir/file1.txt', b'data1')
await provider.save('testdir/file2.txt', b'data2')
await provider.save('testdir/subdir/file3.txt', b'data3')
await provider.save('otherdir/file.txt', b'data4')
# Delete directory
await provider.delete_dir_recursive('testdir')
# Verify testdir objects are deleted
assert await provider.exists('testdir/file1.txt') is False
assert await provider.exists('testdir/file2.txt') is False
assert await provider.exists('testdir/subdir/file3.txt') is False
# Verify other directory is intact
assert await provider.exists('otherdir/file.txt') is True
@pytest.mark.asyncio
async def test_delete_dir_recursive_handles_trailing_slash(self, mock_app_with_s3_config, s3_mock):
"""Test that delete_dir_recursive handles path without trailing slash."""
s3storage = get_s3storage_module()
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
# Save object
await provider.save('mydir/file.txt', b'data')
# Delete without trailing slash
await provider.delete_dir_recursive('mydir')
# Verify deleted
assert await provider.exists('mydir/file.txt') is False
@pytest.mark.asyncio
async def test_delete_dir_recursive_empty_directory(self, mock_app_with_s3_config, s3_mock):
"""Test that delete_dir_recursive handles empty directory."""
s3storage = get_s3storage_module()
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
# Delete non-existent directory should not raise
await provider.delete_dir_recursive('emptydir')
@pytest.mark.asyncio
async def test_multiple_saves_and_loads(self, mock_app_with_s3_config, s3_mock):
"""Test multiple save/load operations."""
s3storage = get_s3storage_module()
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
# Save multiple files
files = {
'file1.txt': b'content1',
'file2.txt': b'content2',
'dir/file3.txt': b'content3',
}
for key, data in files.items():
await provider.save(key, data)
# Load and verify all
for key, expected in files.items():
loaded = await provider.load(key)
assert loaded == expected
@pytest.mark.asyncio
async def test_overwrite_existing_object(self, mock_app_with_s3_config, s3_mock):
"""Test that save overwrites existing object."""
s3storage = get_s3storage_module()
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
# Save initial data
await provider.save('file.txt', b'initial')
# Overwrite
await provider.save('file.txt', b'overwritten')
# Verify new content
loaded = await provider.load('file.txt')
assert loaded == b'overwritten'
class TestS3StorageProviderErrorHandling:
"""Tests for error handling scenarios."""
@pytest.mark.asyncio
async def test_load_nonexistent_raises_error(self, s3_mock):
"""Test that load raises error for nonexistent object."""
s3storage = get_s3storage_module()
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {
'storage': {
's3': {
'bucket': 'test-bucket',
'access_key_id': 'testing',
'secret_access_key': 'testing',
'region': 'us-east-1',
}
}
}
mock_app.logger = Mock()
provider = s3storage.S3StorageProvider(mock_app)
await provider.initialize()
with pytest.raises(Exception):
await provider.load('nonexistent.txt')
@pytest.mark.asyncio
async def test_size_nonexistent_raises_error(self, s3_mock):
"""Test that size raises error for nonexistent object."""
s3storage = get_s3storage_module()
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {
'storage': {
's3': {
'bucket': 'test-bucket',
'access_key_id': 'testing',
'secret_access_key': 'testing',
'region': 'us-east-1',
}
}
}
mock_app.logger = Mock()
provider = s3storage.S3StorageProvider(mock_app)
await provider.initialize()
with pytest.raises(Exception):
await provider.size('nonexistent.txt')
@@ -0,0 +1,126 @@
"""
Tests for langbot.pkg.storage.mgr module.
Tests storage manager initialization and provider selection.
"""
import pytest
from unittest.mock import Mock, AsyncMock, patch
from langbot.pkg.storage.mgr import StorageMgr
from langbot.pkg.storage.providers.localstorage import LocalStorageProvider
from langbot.pkg.storage.providers.s3storage import S3StorageProvider
class TestStorageMgr:
"""Test StorageMgr class."""
def test_init_stores_app_reference(self):
"""StorageMgr should store the application reference."""
mock_app = Mock()
storage_mgr = StorageMgr(mock_app)
assert storage_mgr.ap == mock_app
@pytest.mark.asyncio
async def test_initialize_default_local(self):
"""Should use local storage by default."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {}
mock_app.logger = Mock()
storage_mgr = StorageMgr(mock_app)
with patch.object(LocalStorageProvider, "initialize", new_callable=AsyncMock):
await storage_mgr.initialize()
assert isinstance(storage_mgr.storage_provider, LocalStorageProvider)
mock_app.logger.info.assert_called()
@pytest.mark.asyncio
async def test_initialize_with_explicit_local(self):
"""Should use local storage when explicitly configured."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {"storage": {"use": "local"}}
mock_app.logger = Mock()
storage_mgr = StorageMgr(mock_app)
with patch.object(LocalStorageProvider, "initialize", new_callable=AsyncMock):
await storage_mgr.initialize()
assert isinstance(storage_mgr.storage_provider, LocalStorageProvider)
@pytest.mark.asyncio
async def test_initialize_with_s3(self):
"""Should use S3 storage when configured."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {
"storage": {"use": "s3", "s3": {"endpoint_url": "https://s3.amazonaws.com"}}
}
mock_app.logger = Mock()
storage_mgr = StorageMgr(mock_app)
with patch.object(S3StorageProvider, "initialize", new_callable=AsyncMock):
await storage_mgr.initialize()
assert isinstance(storage_mgr.storage_provider, S3StorageProvider)
@pytest.mark.asyncio
async def test_initialize_invalid_type_defaults_to_local(self):
"""Should default to local storage for invalid storage type."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {"storage": {"use": "invalid_type"}}
mock_app.logger = Mock()
storage_mgr = StorageMgr(mock_app)
with patch.object(LocalStorageProvider, "initialize", new_callable=AsyncMock):
await storage_mgr.initialize()
assert isinstance(storage_mgr.storage_provider, LocalStorageProvider)
@pytest.mark.asyncio
async def test_initialize_calls_provider_initialize(self):
"""Should call the provider's initialize method."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {}
mock_app.logger = Mock()
storage_mgr = StorageMgr(mock_app)
with patch.object(
LocalStorageProvider, "initialize", new_callable=AsyncMock
) as mock_init:
await storage_mgr.initialize()
mock_init.assert_called_once()
class TestStorageProviderBase:
"""Test StorageProvider base class methods."""
def test_provider_stores_app_reference(self):
"""Provider should store app reference."""
mock_app = Mock()
# Use LocalStorageProvider as concrete implementation
with patch("os.path.exists", return_value=True):
with patch("os.makedirs"):
provider = LocalStorageProvider(mock_app)
assert provider.ap == mock_app
@pytest.mark.asyncio
async def test_provider_base_initialize(self):
"""Provider base initialize should be callable and do nothing."""
mock_app = Mock()
with patch("os.path.exists", return_value=True):
with patch("os.makedirs"):
provider = LocalStorageProvider(mock_app)
# Initialize should not raise
await provider.initialize()
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,352 @@
"""Unit tests for survey manager.
Tests cover:
- SurveyManager initialization
- Event triggering and tracking
- Pending survey fetching
- Survey response submission
- Survey dismissal
"""
from __future__ import annotations
import pytest
import json
from unittest.mock import Mock, AsyncMock, MagicMock
from importlib import import_module
def get_survey_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.survey.manager')
def create_mock_app():
"""Create mock Application for testing."""
mock_app = Mock()
mock_app.logger = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {'space': {'url': 'https://space.example.com'}}
mock_app.persistence_mgr = AsyncMock()
mock_app.persistence_mgr.execute_async = AsyncMock()
return mock_app
class TestSurveyManagerInit:
"""Tests for SurveyManager initialization."""
def test_init_stores_app_reference(self):
"""Test that __init__ stores Application reference."""
survey_module = get_survey_module()
mock_app = create_mock_app()
manager = survey_module.SurveyManager(mock_app)
assert manager.ap is mock_app
def test_init_creates_empty_triggered_events(self):
"""Test that triggered_events starts as empty set."""
survey_module = get_survey_module()
mock_app = create_mock_app()
manager = survey_module.SurveyManager(mock_app)
assert manager._triggered_events == set()
def test_init_pending_survey_is_none(self):
"""Test that pending_survey starts as None."""
survey_module = get_survey_module()
mock_app = create_mock_app()
manager = survey_module.SurveyManager(mock_app)
assert manager._pending_survey is None
@pytest.mark.asyncio
async def test_initialize_loads_space_url(self):
"""Test that initialize loads space URL from config."""
survey_module = get_survey_module()
mock_app = create_mock_app()
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
manager = survey_module.SurveyManager(mock_app)
await manager.initialize()
assert manager._space_url == 'https://space.example.com'
@pytest.mark.asyncio
async def test_initialize_strips_trailing_slash_from_url(self):
"""Test that trailing slash is stripped from URL."""
survey_module = get_survey_module()
mock_app = create_mock_app()
mock_app.instance_config.data = {'space': {'url': 'https://space.example.com/'}}
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
manager = survey_module.SurveyManager(mock_app)
await manager.initialize()
assert manager._space_url == 'https://space.example.com'
@pytest.mark.asyncio
async def test_initialize_handles_empty_space_config(self):
"""Test that initialize handles empty space config."""
survey_module = get_survey_module()
mock_app = create_mock_app()
mock_app.instance_config.data = {}
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
manager = survey_module.SurveyManager(mock_app)
await manager.initialize()
assert manager._space_url == ''
class TestLoadTriggeredEvents:
"""Tests for _load_triggered_events method."""
@pytest.mark.asyncio
async def test_loads_events_from_metadata(self):
"""Test that events are loaded from metadata table."""
survey_module = get_survey_module()
mock_app = create_mock_app()
# Mock existing metadata row
mock_row = Mock()
mock_row.value = json.dumps(['event1', 'event2'])
mock_result = Mock()
mock_result.first = Mock(return_value=(mock_row,))
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
manager = survey_module.SurveyManager(mock_app)
await manager._load_triggered_events()
assert 'event1' in manager._triggered_events
assert 'event2' in manager._triggered_events
@pytest.mark.asyncio
async def test_handles_no_existing_events(self):
"""Test that empty set is used when no events stored."""
survey_module = get_survey_module()
mock_app = create_mock_app()
mock_app.persistence_mgr.execute_async = AsyncMock(
return_value=Mock(first=Mock(return_value=None))
)
manager = survey_module.SurveyManager(mock_app)
await manager._load_triggered_events()
assert manager._triggered_events == set()
@pytest.mark.asyncio
async def test_handles_exception(self):
"""Test that exception results in empty set."""
survey_module = get_survey_module()
mock_app = create_mock_app()
mock_app.persistence_mgr.execute_async = AsyncMock(side_effect=Exception('DB error'))
manager = survey_module.SurveyManager(mock_app)
await manager._load_triggered_events()
assert manager._triggered_events == set()
class TestIsSpaceConfigured:
"""Tests for _is_space_configured method."""
def test_returns_true_when_url_set(self):
"""Test returns True when space URL is configured."""
survey_module = get_survey_module()
mock_app = create_mock_app()
manager = survey_module.SurveyManager(mock_app)
manager._space_url = 'https://space.example.com'
assert manager._is_space_configured() is True
def test_returns_false_when_url_empty(self):
"""Test returns False when space URL is empty."""
survey_module = get_survey_module()
mock_app = create_mock_app()
manager = survey_module.SurveyManager(mock_app)
manager._space_url = ''
assert manager._is_space_configured() is False
def test_returns_false_when_telemetry_disabled(self):
"""Test returns False when disable_telemetry is True."""
survey_module = get_survey_module()
mock_app = create_mock_app()
mock_app.instance_config.data = {'space': {'url': 'https://space.example.com', 'disable_telemetry': True}}
manager = survey_module.SurveyManager(mock_app)
manager._space_url = 'https://space.example.com'
assert manager._is_space_configured() is False
class TestTriggerEvent:
"""Tests for trigger_event method."""
@pytest.mark.asyncio
async def test_skips_already_triggered_event(self):
"""Test that already triggered events are skipped."""
survey_module = get_survey_module()
mock_app = create_mock_app()
manager = survey_module.SurveyManager(mock_app)
manager._triggered_events.add('event1')
await manager.trigger_event('event1')
# Should not call save
mock_app.persistence_mgr.execute_async.assert_not_called()
@pytest.mark.asyncio
async def test_skips_when_space_not_configured(self):
"""Test that event is skipped when space not configured."""
survey_module = get_survey_module()
mock_app = create_mock_app()
manager = survey_module.SurveyManager(mock_app)
manager._space_url = ''
await manager.trigger_event('new_event')
assert 'new_event' not in manager._triggered_events
@pytest.mark.asyncio
async def test_adds_new_event_and_saves(self):
"""Test that new event is added and saved."""
survey_module = get_survey_module()
mock_app = create_mock_app()
mock_app.persistence_mgr.execute_async = AsyncMock(
return_value=Mock(first=Mock(return_value=None))
)
manager = survey_module.SurveyManager(mock_app)
manager._space_url = 'https://space.example.com'
await manager.trigger_event('new_event')
assert 'new_event' in manager._triggered_events
class TestPendingSurvey:
"""Tests for get_pending_survey and clear_pending_survey."""
def test_returns_none_when_no_pending(self):
"""Test returns None when no pending survey."""
survey_module = get_survey_module()
mock_app = create_mock_app()
manager = survey_module.SurveyManager(mock_app)
assert manager.get_pending_survey() is None
def test_returns_pending_survey(self):
"""Test returns the pending survey."""
survey_module = get_survey_module()
mock_app = create_mock_app()
manager = survey_module.SurveyManager(mock_app)
manager._pending_survey = {'survey_id': '123', 'questions': []}
result = manager.get_pending_survey()
assert result['survey_id'] == '123'
def test_clear_pending_survey(self):
"""Test that clear_pending_survey sets to None."""
survey_module = get_survey_module()
mock_app = create_mock_app()
manager = survey_module.SurveyManager(mock_app)
manager._pending_survey = {'survey_id': '123'}
manager.clear_pending_survey()
assert manager._pending_survey is None
class TestSubmitResponse:
"""Tests for submit_response method."""
@pytest.mark.asyncio
async def test_returns_false_when_space_not_configured(self):
"""Test returns False when space not configured."""
survey_module = get_survey_module()
mock_app = create_mock_app()
manager = survey_module.SurveyManager(mock_app)
manager._space_url = ''
result = await manager.submit_response('survey123', {'q1': 'answer1'})
assert result is False
@pytest.mark.asyncio
async def test_clears_pending_on_success(self):
"""Test that pending survey is cleared on success."""
survey_module = get_survey_module()
mock_app = create_mock_app()
manager = survey_module.SurveyManager(mock_app)
manager._space_url = 'https://space.example.com'
manager._pending_survey = {'survey_id': 'survey123'}
# Mock successful HTTP response
import httpx
mock_response = Mock()
mock_response.status_code = 200
with pytest.MonkeyPatch().context() as m:
m.setattr(httpx, 'AsyncClient', lambda **kwargs: MagicMock(
__aenter__=AsyncMock(return_value=Mock(post=AsyncMock(return_value=mock_response))),
__aexit__=AsyncMock(return_value=None)
))
result = await manager.submit_response('survey123', {'q1': 'answer1'})
assert result is True
assert manager._pending_survey is None
class TestDismissSurvey:
"""Tests for dismiss_survey method."""
@pytest.mark.asyncio
async def test_returns_false_when_space_not_configured(self):
"""Test returns False when space not configured."""
survey_module = get_survey_module()
mock_app = create_mock_app()
manager = survey_module.SurveyManager(mock_app)
manager._space_url = ''
result = await manager.dismiss_survey('survey123')
assert result is False
@pytest.mark.asyncio
async def test_clears_pending_on_success(self):
"""Test that pending survey is cleared on success."""
survey_module = get_survey_module()
mock_app = create_mock_app()
manager = survey_module.SurveyManager(mock_app)
manager._space_url = 'https://space.example.com'
manager._pending_survey = {'survey_id': 'survey123'}
# Mock successful HTTP response
import httpx
mock_response = Mock()
mock_response.status_code = 200
with pytest.MonkeyPatch().context() as m:
m.setattr(httpx, 'AsyncClient', lambda **kwargs: MagicMock(
__aenter__=AsyncMock(return_value=Mock(post=AsyncMock(return_value=mock_response))),
__aexit__=AsyncMock(return_value=None)
))
result = await manager.dismiss_survey('survey123')
assert result is True
assert manager._pending_survey is None
@@ -0,0 +1,622 @@
"""Unit tests for telemetry module.
Tests cover:
- TelemetryManager initialization
- Payload sanitization logic (with real behavior verification)
- Early return conditions (disabled, empty config, no server)
- URL construction (with actual URL verification)
- HTTP request success/failure scenarios
- Source code bug: send_tasks should be instance variable
"""
from __future__ import annotations
import pytest
import httpx
from unittest.mock import AsyncMock, Mock, patch
from importlib import import_module
def get_telemetry_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.telemetry.telemetry')
class TestTelemetryManagerInit:
"""Tests for TelemetryManager initialization."""
def test_init_stores_app_reference(self):
"""Test that __init__ stores the Application reference."""
telemetry = get_telemetry_module()
mock_app = Mock()
manager = telemetry.TelemetryManager(mock_app)
assert manager.ap is mock_app
def test_init_empty_telemetry_config(self):
"""Test that telemetry_config starts empty."""
telemetry = get_telemetry_module()
mock_app = Mock()
manager = telemetry.TelemetryManager(mock_app)
assert manager.telemetry_config == {}
class TestTelemetryManagerInitialize:
"""Tests for initialize() method."""
@pytest.mark.asyncio
async def test_initialize_loads_space_config(self):
"""Test that initialize() loads space config from instance_config."""
telemetry = get_telemetry_module()
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {'space': {'url': 'https://example.com'}}
manager = telemetry.TelemetryManager(mock_app)
await manager.initialize()
assert manager.telemetry_config == {'url': 'https://example.com'}
@pytest.mark.asyncio
async def test_initialize_handles_empty_space_config(self):
"""Test that initialize() handles missing space config."""
telemetry = get_telemetry_module()
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {}
manager = telemetry.TelemetryManager(mock_app)
await manager.initialize()
assert manager.telemetry_config == {}
class TestTelemetrySendEarlyReturn:
"""Tests for early return conditions in send() method."""
@pytest.mark.asyncio
async def test_send_returns_when_config_empty(self):
"""Test that send() returns early when telemetry_config is empty."""
telemetry = get_telemetry_module()
mock_app = Mock()
mock_app.logger = Mock()
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {}
# Should return without making HTTP calls
await manager.send({'query_id': 'test'})
# No HTTP client should be created, no logs should be written
mock_app.logger.debug.assert_not_called()
mock_app.logger.warning.assert_not_called()
@pytest.mark.asyncio
async def test_send_returns_when_telemetry_disabled(self):
"""Test that send() returns early when disable_telemetry is True."""
telemetry = get_telemetry_module()
mock_app = Mock()
mock_app.logger = Mock()
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {'disable_telemetry': True, 'url': 'https://example.com'}
await manager.send({'query_id': 'test'})
mock_app.logger.debug.assert_not_called()
@pytest.mark.asyncio
async def test_send_returns_when_server_empty(self):
"""Test that send() returns early when server URL is empty."""
telemetry = get_telemetry_module()
mock_app = Mock()
mock_app.logger = Mock()
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {'url': ''}
await manager.send({'query_id': 'test'})
mock_app.logger.debug.assert_not_called()
class TestPayloadSanitization:
"""Tests for payload sanitization logic in send() method.
IMPORTANT: These tests verify actual behavior, not source code strings.
"""
@pytest.mark.asyncio
async def test_sanitize_null_query_id(self):
"""Test that null query_id is converted to empty string."""
telemetry = get_telemetry_module()
mock_app = Mock()
mock_app.logger = Mock()
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {'url': 'https://example.com'}
captured_payloads = []
async def mock_post(url, json):
captured_payloads.append(json)
return Mock(status_code=200, text='', json=Mock(return_value={'code': 0}))
mock_client = Mock()
mock_client.post = mock_post
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch.object(httpx, 'AsyncClient', return_value=mock_client):
await manager.send({'query_id': None})
assert len(captured_payloads) == 1
assert captured_payloads[0]['query_id'] == ''
@pytest.mark.asyncio
async def test_sanitize_query_id_string_value(self):
"""Test that query_id string value is preserved."""
telemetry = get_telemetry_module()
mock_app = Mock()
mock_app.logger = Mock()
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {'url': 'https://example.com'}
captured_payloads = []
async def mock_post(url, json):
captured_payloads.append(json)
return Mock(status_code=200, text='', json=Mock(return_value={'code': 0}))
mock_client = Mock()
mock_client.post = mock_post
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch.object(httpx, 'AsyncClient', return_value=mock_client):
await manager.send({'query_id': 'abc123'})
assert len(captured_payloads) == 1
assert captured_payloads[0]['query_id'] == 'abc123'
@pytest.mark.asyncio
async def test_sanitize_null_string_fields(self):
"""Test that null string fields are converted to empty strings."""
telemetry = get_telemetry_module()
mock_app = Mock()
mock_app.logger = Mock()
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {'url': 'https://example.com'}
captured_payloads = []
async def mock_post(url, json):
captured_payloads.append(json)
return Mock(status_code=200, text='', json=Mock(return_value={'code': 0}))
mock_client = Mock()
mock_client.post = mock_post
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
payload = {
'query_id': 'test',
'adapter': None,
'runner': None,
'runner_category': None,
'model_name': None,
'version': None,
'edition': None,
'error': None,
'timestamp': None,
}
with patch.object(httpx, 'AsyncClient', return_value=mock_client):
await manager.send(payload)
assert len(captured_payloads) == 1
result = captured_payloads[0]
# All null string fields should be empty strings
for field in ['adapter', 'runner', 'runner_category', 'model_name', 'version', 'edition', 'error', 'timestamp']:
assert result[field] == '', f"Field {field} should be empty string, got {result[field]}"
@pytest.mark.asyncio
async def test_sanitize_string_fields_preserve_values(self):
"""Test that non-null string fields preserve their values."""
telemetry = get_telemetry_module()
mock_app = Mock()
mock_app.logger = Mock()
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {'url': 'https://example.com'}
captured_payloads = []
async def mock_post(url, json):
captured_payloads.append(json)
return Mock(status_code=200, text='', json=Mock(return_value={'code': 0}))
mock_client = Mock()
mock_client.post = mock_post
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
payload = {
'query_id': 'test',
'adapter': 'gewechat',
'runner': 'local-agent',
'model_name': 'gpt-4',
'version': 'v1.0.0',
}
with patch.object(httpx, 'AsyncClient', return_value=mock_client):
await manager.send(payload)
assert len(captured_payloads) == 1
result = captured_payloads[0]
assert result['adapter'] == 'gewechat'
assert result['runner'] == 'local-agent'
assert result['model_name'] == 'gpt-4'
assert result['version'] == 'v1.0.0'
@pytest.mark.asyncio
async def test_sanitize_duration_ms_invalid_value(self):
"""Test that invalid duration_ms is converted to 0."""
telemetry = get_telemetry_module()
mock_app = Mock()
mock_app.logger = Mock()
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {'url': 'https://example.com'}
captured_payloads = []
async def mock_post(url, json):
captured_payloads.append(json)
return Mock(status_code=200, text='', json=Mock(return_value={'code': 0}))
mock_client = Mock()
mock_client.post = mock_post
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch.object(httpx, 'AsyncClient', return_value=mock_client):
await manager.send({'query_id': 'test', 'duration_ms': 'invalid'})
assert len(captured_payloads) == 1
assert captured_payloads[0]['duration_ms'] == 0
@pytest.mark.asyncio
async def test_sanitize_duration_ms_none_value(self):
"""Test that None duration_ms is converted to 0."""
telemetry = get_telemetry_module()
mock_app = Mock()
mock_app.logger = Mock()
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {'url': 'https://example.com'}
captured_payloads = []
async def mock_post(url, json):
captured_payloads.append(json)
return Mock(status_code=200, text='', json=Mock(return_value={'code': 0}))
mock_client = Mock()
mock_client.post = mock_post
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch.object(httpx, 'AsyncClient', return_value=mock_client):
await manager.send({'query_id': 'test', 'duration_ms': None})
assert len(captured_payloads) == 1
assert captured_payloads[0]['duration_ms'] == 0
@pytest.mark.asyncio
async def test_sanitize_duration_ms_valid_value(self):
"""Test that valid duration_ms is converted to int."""
telemetry = get_telemetry_module()
mock_app = Mock()
mock_app.logger = Mock()
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {'url': 'https://example.com'}
captured_payloads = []
async def mock_post(url, json):
captured_payloads.append(json)
return Mock(status_code=200, text='', json=Mock(return_value={'code': 0}))
mock_client = Mock()
mock_client.post = mock_post
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch.object(httpx, 'AsyncClient', return_value=mock_client):
await manager.send({'query_id': 'test', 'duration_ms': 123.45})
assert len(captured_payloads) == 1
assert captured_payloads[0]['duration_ms'] == 123
class TestURLConstruction:
"""Tests for URL construction in send() method.
IMPORTANT: These tests verify actual URLs sent, not source code strings.
"""
@pytest.mark.asyncio
async def test_url_strip_trailing_slash(self):
"""Test that trailing slash is stripped from server URL."""
telemetry = get_telemetry_module()
mock_app = Mock()
mock_app.logger = Mock()
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {'url': 'https://example.com/'}
captured_urls = []
async def mock_post(url, json):
captured_urls.append(url)
return Mock(status_code=200, text='', json=Mock(return_value={'code': 0}))
mock_client = Mock()
mock_client.post = mock_post
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch.object(httpx, 'AsyncClient', return_value=mock_client):
await manager.send({'query_id': 'test'})
assert len(captured_urls) == 1
assert captured_urls[0] == 'https://example.com/api/v1/telemetry'
# No trailing slash before /api/v1/telemetry
@pytest.mark.asyncio
async def test_url_without_trailing_slash(self):
"""Test that URL without trailing slash works correctly."""
telemetry = get_telemetry_module()
mock_app = Mock()
mock_app.logger = Mock()
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {'url': 'https://example.com'}
captured_urls = []
async def mock_post(url, json):
captured_urls.append(url)
return Mock(status_code=200, text='', json=Mock(return_value={'code': 0}))
mock_client = Mock()
mock_client.post = mock_post
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch.object(httpx, 'AsyncClient', return_value=mock_client):
await manager.send({'query_id': 'test'})
assert len(captured_urls) == 1
assert captured_urls[0] == 'https://example.com/api/v1/telemetry'
class TestHTTPScenarios:
"""Tests for HTTP request success/failure scenarios."""
@pytest.mark.asyncio
async def test_send_http_success_logs_debug(self):
"""Test that HTTP 200 with code=0 logs debug message."""
telemetry = get_telemetry_module()
mock_app = Mock()
mock_app.logger = Mock()
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {'url': 'https://example.com'}
mock_response = Mock(
status_code=200,
text='{"code": 0, "msg": "success"}',
json=Mock(return_value={'code': 0, 'msg': 'success'})
)
mock_client = Mock()
mock_client.post = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch.object(httpx, 'AsyncClient', return_value=mock_client):
await manager.send({'query_id': 'test'})
mock_app.logger.debug.assert_called()
# Verify debug message contains URL and status
debug_call_args = mock_app.logger.debug.call_args[0][0]
assert 'Telemetry posted' in debug_call_args
assert 'https://example.com/api/v1/telemetry' in debug_call_args
@pytest.mark.asyncio
async def test_send_http_error_status_logs_warning(self):
"""Test that HTTP status >= 400 logs warning."""
telemetry = get_telemetry_module()
mock_app = Mock()
mock_app.logger = Mock()
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {'url': 'https://example.com'}
mock_response = Mock(
status_code=500,
text='Internal Server Error',
json=Mock(return_value={'code': 500, 'msg': 'error'})
)
mock_client = Mock()
mock_client.post = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch.object(httpx, 'AsyncClient', return_value=mock_client):
await manager.send({'query_id': 'test'})
mock_app.logger.warning.assert_called()
warning_call_args = mock_app.logger.warning.call_args[0][0]
assert 'status 500' in warning_call_args
@pytest.mark.asyncio
async def test_send_application_error_logs_warning(self):
"""Test that HTTP 200 with application code >= 400 logs warning."""
telemetry = get_telemetry_module()
mock_app = Mock()
mock_app.logger = Mock()
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {'url': 'https://example.com'}
mock_response = Mock(
status_code=200,
text='{"code": 400, "msg": "Bad Request"}',
json=Mock(return_value={'code': 400, 'msg': 'Bad Request'})
)
mock_client = Mock()
mock_client.post = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch.object(httpx, 'AsyncClient', return_value=mock_client):
await manager.send({'query_id': 'test'})
# Source code calls warning twice for application errors
assert mock_app.logger.warning.call_count >= 1
# Check that one of the calls contains application error info
all_warnings = [call[0][0] for call in mock_app.logger.warning.call_args_list]
assert any('400' in w for w in all_warnings), f"No warning contained error code 400: {all_warnings}"
@pytest.mark.asyncio
async def test_send_timeout_logs_warning(self):
"""Test that asyncio.TimeoutError logs warning."""
telemetry = get_telemetry_module()
mock_app = Mock()
mock_app.logger = Mock()
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {'url': 'https://example.com'}
import asyncio
async def mock_post_timeout(url, json):
raise asyncio.TimeoutError()
mock_client = Mock()
mock_client.post = mock_post_timeout
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch.object(httpx, 'AsyncClient', return_value=mock_client):
await manager.send({'query_id': 'test'})
mock_app.logger.warning.assert_called()
warning_call_args = mock_app.logger.warning.call_args[0][0]
assert 'timed out' in warning_call_args
@pytest.mark.asyncio
async def test_send_network_error_logs_warning(self):
"""Test that network exceptions log warning without raising."""
telemetry = get_telemetry_module()
mock_app = Mock()
mock_app.logger = Mock()
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {'url': 'https://example.com'}
async def mock_post_error(url, json):
raise httpx.ConnectError('Connection failed')
mock_client = Mock()
mock_client.post = mock_post_error
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch.object(httpx, 'AsyncClient', return_value=mock_client):
# Should not raise exception
await manager.send({'query_id': 'test'})
mock_app.logger.warning.assert_called()
@pytest.mark.asyncio
async def test_send_never_raises_exception(self):
"""Test that send() never raises exceptions regardless of errors."""
telemetry = get_telemetry_module()
mock_app = Mock()
# Even logger may fail
mock_app.logger = Mock()
mock_app.logger.warning = Mock(side_effect=Exception('Logger failed'))
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {'url': 'https://example.com'}
async def mock_post_error(url, json):
raise Exception('Unexpected error')
mock_client = Mock()
mock_client.post = mock_post_error
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch.object(httpx, 'AsyncClient', return_value=mock_client):
# Should never raise
await manager.send({'query_id': 'test'})
class TestStartSendTask:
"""Tests for start_send_task() method."""
@pytest.mark.asyncio
async def test_start_send_task_creates_task(self):
"""Test that start_send_task creates an asyncio task."""
telemetry = get_telemetry_module()
mock_app = Mock()
mock_app.logger = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {}
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {}
await manager.start_send_task({'query_id': 'test'})
# Task should be added to send_tasks list
assert len(manager.send_tasks) >= 1
# Clean up the task
for task in manager.send_tasks:
if not task.done():
task.cancel()
manager.send_tasks.clear()
@pytest.mark.asyncio
async def test_start_send_task_multiple_tasks(self):
"""Test that multiple tasks are tracked."""
telemetry = get_telemetry_module()
mock_app = Mock()
mock_app.logger = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {}
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {}
await manager.start_send_task({'query_id': 'test1'})
await manager.start_send_task({'query_id': 'test2'})
await manager.start_send_task({'query_id': 'test3'})
assert len(manager.send_tasks) >= 3
# Clean up
for task in manager.send_tasks:
if not task.done():
task.cancel()
manager.send_tasks.clear()
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from langbot.pkg.telemetry.telemetry import TelemetryManager
@pytest.mark.asyncio
async def test_send_tasks_are_scoped_to_manager_instance(monkeypatch):
async def fake_send(self, payload):
return payload
monkeypatch.setattr(TelemetryManager, 'send', fake_send)
first = TelemetryManager(SimpleNamespace())
second = TelemetryManager(SimpleNamespace())
assert first.send_tasks is not second.send_tasks
await first.start_send_task({'event': 'first'})
await first.send_tasks[0]
assert len(first.send_tasks) == 1
assert second.send_tasks == []
+1
View File
@@ -0,0 +1 @@
+208
View File
@@ -0,0 +1,208 @@
"""Unit tests for utils funcschema.
Tests cover:
- get_func_schema() function
- Docstring parsing
- Parameter type extraction
- Required parameter detection
Note: Do NOT use 'from __future__ import annotations' because
funcschema.py expects actual type objects, not string annotations.
"""
import pytest
from importlib import import_module
def get_funcschema_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.utils.funcschema')
class TestGetFuncSchema:
"""Tests for get_func_schema function."""
def test_simple_function_schema(self):
"""Test schema generation for simple function."""
funcschema = get_funcschema_module()
def simple_func(name: str, count: int):
"""Simple function description.
Args:
name: The name parameter.
count: The count parameter.
"""
pass
result = funcschema.get_func_schema(simple_func)
assert result['description'] == 'Simple function description.'
assert result['parameters']['type'] == 'object'
assert 'name' in result['parameters']['properties']
assert 'count' in result['parameters']['properties']
assert result['parameters']['properties']['name']['type'] == 'string'
assert result['parameters']['properties']['count']['type'] == 'integer'
def test_parameter_type_mapping(self):
"""Test that Python types are mapped to JSON schema types."""
funcschema = get_funcschema_module()
def typed_func(a: str, b: int, c: float, d: bool, e: list, f: dict):
"""Typed function.
Args:
a: String param.
b: Int param.
c: Float param.
d: Bool param.
e: List param.
f: Dict param.
"""
pass
result = funcschema.get_func_schema(typed_func)
props = result['parameters']['properties']
assert props['a']['type'] == 'string'
assert props['b']['type'] == 'integer'
assert props['c']['type'] == 'number'
assert props['d']['type'] == 'boolean'
assert props['e']['type'] == 'array'
assert props['f']['type'] == 'object'
def test_required_parameters_detection(self):
"""Test that required parameters are detected correctly."""
funcschema = get_funcschema_module()
def func_with_defaults(name: str, optional: str = 'default'):
"""Function with default.
Args:
name: Required param.
optional: Optional param.
"""
pass
result = funcschema.get_func_schema(func_with_defaults)
assert 'name' in result['parameters']['required']
assert 'optional' not in result['parameters']['required']
def test_self_and_query_excluded(self):
"""Test that self and query parameters are excluded."""
funcschema = get_funcschema_module()
def method_func(self, query, other: str):
"""Method function.
Args:
self: Self parameter.
query: Query parameter.
other: Other parameter.
"""
pass
result = funcschema.get_func_schema(method_func)
props = result['parameters']['properties']
assert 'self' not in props
assert 'query' not in props
assert 'other' in props
def test_array_type_extraction(self):
"""Test that list[T] types extract element type."""
funcschema = get_funcschema_module()
def list_func(items: list[str], numbers: list[int]):
"""List function.
Args:
items: List of strings.
numbers: List of integers.
"""
pass
result = funcschema.get_func_schema(list_func)
props = result['parameters']['properties']
assert props['items']['type'] == 'array'
assert props['items']['items']['type'] == 'string'
assert props['numbers']['type'] == 'array'
assert props['numbers']['items']['type'] == 'integer'
def test_function_without_docstring_raises(self):
"""Test that function without docstring raises exception."""
funcschema = get_funcschema_module()
def no_doc_func(a: str):
pass
with pytest.raises(Exception) as exc_info:
funcschema.get_func_schema(no_doc_func)
assert 'has no docstring' in str(exc_info.value)
def test_description_extraction(self):
"""Test that description is extracted from first paragraph."""
funcschema = get_funcschema_module()
def desc_func(a: str):
"""This is the description.
Args:
a: Param a.
"""
pass
result = funcschema.get_func_schema(desc_func)
assert result['description'] == 'This is the description.'
def test_function_reference_stored(self):
"""Test that function reference is stored in schema."""
funcschema = get_funcschema_module()
def stored_func(a: str):
"""Stored function.
Args:
a: Param a.
"""
pass
result = funcschema.get_func_schema(stored_func)
assert result['function'] is stored_func
def test_description_from_args_doc(self):
"""Test that arg description is extracted from docstring."""
funcschema = get_funcschema_module()
def doc_func(param_name: str):
"""Function with documented param.
Args:
param_name: This is the param description.
"""
pass
result = funcschema.get_func_schema(doc_func)
assert result['parameters']['properties']['param_name']['description'] == 'This is the param description.'
def test_missing_parameter_doc_uses_empty_description(self):
"""Undocumented parameters should not break schema generation."""
funcschema = get_funcschema_module()
def sample_function(documented: str, undocumented: int):
"""Sample function.
Args:
documented(str): documented parameter description
"""
pass
result = funcschema.get_func_schema(sample_function)
assert result['parameters']['properties']['documented']['description'] == 'documented parameter description'
assert result['parameters']['properties']['undocumented']['description'] == ''
+146
View File
@@ -0,0 +1,146 @@
"""
Unit tests for HTTP client session pool.
Tests session management, reuse, and cleanup.
"""
from __future__ import annotations
import pytest
import aiohttp
from aiohttp import web
from langbot.pkg.utils import httpclient
pytestmark = pytest.mark.asyncio
class TestGetSession:
"""Tests for get_session function."""
async def test_get_session_returns_client_session(self):
"""get_session returns an aiohttp.ClientSession."""
session = httpclient.get_session()
assert isinstance(session, aiohttp.ClientSession)
assert not session.closed
# Cleanup
await session.close()
async def test_get_session_returns_same_instance(self):
"""get_session returns the same session for same trust_env."""
session1 = httpclient.get_session(trust_env=False)
session2 = httpclient.get_session(trust_env=False)
assert session1 is session2
# Cleanup
await session1.close()
async def test_get_session_different_trust_env_creates_different(self):
"""Different trust_env values create different sessions."""
session1 = httpclient.get_session(trust_env=False)
session2 = httpclient.get_session(trust_env=True)
assert session1 is not session2
# Cleanup
await session1.close()
await session2.close()
async def test_get_session_recreates_if_closed(self):
"""get_session creates new session if previous is closed."""
session1 = httpclient.get_session()
await session1.close()
session2 = httpclient.get_session()
assert session2 is not session1
assert not session2.closed
# Cleanup
await session2.close()
class TestCloseAll:
"""Tests for close_all function."""
async def test_close_all_closes_all_sessions(self):
"""close_all closes all sessions."""
# Create multiple sessions
session1 = httpclient.get_session(trust_env=False)
session2 = httpclient.get_session(trust_env=True)
await httpclient.close_all()
assert session1.closed
assert session2.closed
async def test_close_all_clears_pool(self):
"""close_all clears the session pool."""
httpclient.get_session()
httpclient.get_session(trust_env=True)
await httpclient.close_all()
assert len(httpclient._sessions) == 0
async def test_close_all_handles_already_closed(self):
"""close_all handles already closed sessions gracefully."""
session = httpclient.get_session()
await session.close()
# Should not raise
await httpclient.close_all()
async def test_close_all_idempotent(self):
"""close_all can be called multiple times."""
httpclient.get_session()
await httpclient.close_all()
await httpclient.close_all() # Should not raise
assert len(httpclient._sessions) == 0
class TestSessionPoolIntegration:
"""Integration tests for session pool behavior."""
async def test_session_can_make_request(self):
"""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()
try:
async with session.get(
f'http://127.0.0.1:{port}/get',
timeout=aiohttp.ClientTimeout(total=5),
) as resp:
assert resp.status == 200
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."""
session = httpclient.get_session()
# Both calls return the same session
session2 = httpclient.get_session()
assert session is session2
await httpclient.close_all()
+158
View File
@@ -0,0 +1,158 @@
"""
Unit tests for image utility functions.
Tests URL parsing and base64 extraction without network calls.
"""
from __future__ import annotations
import pytest
import base64
from langbot.pkg.utils.image import (
get_qq_image_downloadable_url,
extract_b64_and_format,
)
class TestGetQQImageDownloadableUrl:
"""Tests for get_qq_image_downloadable_url function."""
def test_basic_url(self):
"""Parse basic image URL."""
url = "http://example.com/image.jpg"
result_url, query = get_qq_image_downloadable_url(url)
assert result_url == "http://example.com/image.jpg"
assert query == {}
def test_url_with_query_params(self):
"""Parse URL with query parameters."""
url = "http://example.com/image.jpg?param1=value1&param2=value2"
result_url, query = get_qq_image_downloadable_url(url)
assert result_url == "http://example.com/image.jpg"
assert query == {"param1": ["value1"], "param2": ["value2"]}
def test_url_with_port(self):
"""Parse URL with port number."""
url = "http://example.com:8080/image.jpg"
result_url, query = get_qq_image_downloadable_url(url)
assert result_url == "http://example.com:8080/image.jpg"
def test_url_with_path(self):
"""Parse URL with complex path."""
url = "http://example.com/path/to/image.jpg"
result_url, query = get_qq_image_downloadable_url(url)
assert result_url == "http://example.com/path/to/image.jpg"
def test_url_with_fragment(self):
"""Parse URL with fragment (fragment is not part of query)."""
url = "http://example.com/image.jpg#fragment"
result_url, query = get_qq_image_downloadable_url(url)
# Fragment is not included in query string parsing
assert "http://example.com/image.jpg" in result_url
def test_https_url(self):
"""Parse HTTPS URL and preserve its scheme."""
url = "https://example.com/image.jpg"
result_url, query = get_qq_image_downloadable_url(url)
assert result_url == "https://example.com/image.jpg"
assert query == {}
def test_preserves_qq_https_scheme_and_query(self):
"""QQ image URLs keep HTTPS and query parameters."""
result_url, query = get_qq_image_downloadable_url(
'https://gchat.qpic.cn/gchatpic_new/abc/0?term=2&is_origin=1'
)
assert result_url == 'https://gchat.qpic.cn/gchatpic_new/abc/0'
assert query == {'term': ['2'], 'is_origin': ['1']}
def test_defaults_missing_scheme_to_http(self):
"""Scheme-less image URLs default to HTTP."""
result_url, query = get_qq_image_downloadable_url('gchat.qpic.cn/gchatpic_new/abc/0?term=2')
assert result_url == 'http://gchat.qpic.cn/gchatpic_new/abc/0'
assert query == {'term': ['2']}
class TestExtractB64AndFormat:
"""Tests for extract_b64_and_format function."""
@pytest.mark.asyncio
async def test_jpeg_data_uri(self):
"""Extract base64 and format from JPEG data URI."""
# Create a simple base64 string
original_data = b"test image data"
b64_data = base64.b64encode(original_data).decode()
data_uri = f"data:image/jpeg;base64,{b64_data}"
result_b64, result_format = await extract_b64_and_format(data_uri)
assert result_b64 == b64_data
assert result_format == "jpeg"
@pytest.mark.asyncio
async def test_png_data_uri(self):
"""Extract base64 and format from PNG data URI."""
original_data = b"test png data"
b64_data = base64.b64encode(original_data).decode()
data_uri = f"data:image/png;base64,{b64_data}"
result_b64, result_format = await extract_b64_and_format(data_uri)
assert result_b64 == b64_data
assert result_format == "png"
@pytest.mark.asyncio
async def test_gif_data_uri(self):
"""Extract base64 and format from GIF data URI."""
original_data = b"test gif data"
b64_data = base64.b64encode(original_data).decode()
data_uri = f"data:image/gif;base64,{b64_data}"
result_b64, result_format = await extract_b64_and_format(data_uri)
assert result_b64 == b64_data
assert result_format == "gif"
@pytest.mark.asyncio
async def test_webp_data_uri(self):
"""Extract base64 and format from WebP data URI."""
original_data = b"test webp data"
b64_data = base64.b64encode(original_data).decode()
data_uri = f"data:image/webp;base64,{b64_data}"
result_b64, result_format = await extract_b64_and_format(data_uri)
assert result_b64 == b64_data
assert result_format == "webp"
@pytest.mark.asyncio
async def test_complex_base64(self):
"""Handle base64 with special characters."""
# Base64 can include + and / characters
original_data = bytes(range(256)) # All byte values
b64_data = base64.b64encode(original_data).decode()
data_uri = f"data:image/png;base64,{b64_data}"
result_b64, result_format = await extract_b64_and_format(data_uri)
assert result_b64 == b64_data
# Verify we can decode back to original
assert base64.b64decode(result_b64) == original_data
@pytest.mark.asyncio
async def test_empty_base64(self):
"""Handle empty base64 string."""
data_uri = "data:image/png;base64,"
result_b64, result_format = await extract_b64_and_format(data_uri)
assert result_b64 == ""
assert result_format == "png"
+192
View File
@@ -0,0 +1,192 @@
"""
Tests for langbot.pkg.utils.importutil module.
Tests import utility functions:
- import_dir: imports modules from a directory
- import_modules_in_pkg: imports all modules in a package
- import_modules_in_pkgs: imports all modules in multiple packages
- import_dot_style_dir: imports modules using dot notation path
- read_resource_file: reads a text resource file
- read_resource_file_bytes: reads a binary resource file
- list_resource_files: lists files in a resource directory
Uses mocking for import operations to avoid actual module imports.
"""
import pytest
import importlib
from unittest.mock import patch, MagicMock
class TestImportDir:
"""Test import_dir function."""
def test_calls_importlib_for_each_python_file(self, tmp_path):
"""Should call importlib.import_module for each .py file."""
module_dir = tmp_path / "test_modules"
module_dir.mkdir()
(module_dir / "__init__.py").write_text("")
(module_dir / "module_a.py").write_text("VALUE_A = 'a'\n")
(module_dir / "module_b.py").write_text("VALUE_B = 'b'\n")
(module_dir / "readme.txt").write_text("not a module")
from langbot.pkg.utils import importutil
with patch.object(importlib, "import_module") as mock_import:
importutil.import_dir(str(module_dir), path_prefix="test_prefix.")
# Should call import_module for each .py file (excluding __init__.py)
assert mock_import.call_count == 2
def test_skips_init_py(self, tmp_path):
"""Should skip __init__.py when importing."""
module_dir = tmp_path / "test_modules"
module_dir.mkdir()
(module_dir / "__init__.py").write_text("")
(module_dir / "regular.py").write_text("VALUE = 1\n")
from langbot.pkg.utils import importutil
with patch.object(importlib, "import_module") as mock_import:
importutil.import_dir(str(module_dir), path_prefix="test_prefix.")
# __init__.py should be skipped
mock_import.assert_called_once()
# The call should not include __init__
call_args = mock_import.call_args[0][0]
assert "__init__" not in call_args
def test_ignores_non_py_files(self, tmp_path):
"""Should ignore non-.py files."""
module_dir = tmp_path / "test_modules"
module_dir.mkdir()
(module_dir / "module.py").write_text("VALUE = 1\n")
(module_dir / "readme.txt").write_text("text")
(module_dir / "data.json").write_text("{}")
from langbot.pkg.utils import importutil
with patch.object(importlib, "import_module") as mock_import:
importutil.import_dir(str(module_dir), path_prefix="test_prefix.")
# Only .py files should be imported
assert mock_import.call_count == 1
class TestImportModulesInPkg:
"""Test import_modules_in_pkg function."""
def test_imports_modules_from_package(self, tmp_path):
"""Should import all modules from a package object."""
mock_pkg = MagicMock()
mock_pkg.__file__ = str(tmp_path / "__init__.py")
(tmp_path / "__init__.py").write_text("")
(tmp_path / "mod1.py").write_text("MOD1 = 1\n")
from langbot.pkg.utils import importutil
with patch.object(importutil, "import_dir") as mock_import_dir:
importutil.import_modules_in_pkg(mock_pkg)
mock_import_dir.assert_called_once()
call_path = mock_import_dir.call_args[0][0]
assert call_path == str(tmp_path)
class TestImportModulesInPkgs:
"""Test import_modules_in_pkgs function."""
def test_imports_from_multiple_packages(self):
"""Should call import_modules_in_pkg for each package."""
from langbot.pkg.utils import importutil
mock_pkg1 = MagicMock()
mock_pkg1.__file__ = "/path/to/pkg1/__init__.py"
mock_pkg2 = MagicMock()
mock_pkg2.__file__ = "/path/to/pkg2/__init__.py"
with patch.object(importutil, "import_modules_in_pkg") as mock_import:
importutil.import_modules_in_pkgs([mock_pkg1, mock_pkg2])
assert mock_import.call_count == 2
class TestImportDotStyleDir:
"""Test import_dot_style_dir function."""
def test_converts_dot_notation_to_path(self, tmp_path):
"""Should convert dot notation to path and import."""
# Create structure matching the dot notation
(tmp_path / "my").mkdir()
(tmp_path / "my" / "pkg").mkdir()
(tmp_path / "my" / "pkg" / "test").mkdir()
from langbot.pkg.utils import importutil
with patch.object(importutil, "import_dir") as mock_import_dir:
importutil.import_dot_style_dir("my.pkg.test")
# The path should be converted using os.path.join
call_path = mock_import_dir.call_args[0][0]
# Should contain the path components joined
assert "my" in call_path
class TestReadResourceFile:
"""Test read_resource_file function."""
def test_reads_resource_file_content(self):
"""Should read content from a resource file."""
from langbot.pkg.utils import importutil
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."""
from langbot.pkg.utils import importutil
with pytest.raises((FileNotFoundError, Exception)):
importutil.read_resource_file("nonexistent/path/file.txt")
class TestReadResourceFileBytes:
"""Test read_resource_file_bytes function."""
def test_reads_resource_file_as_bytes(self):
"""Should read content as bytes from a resource file."""
from langbot.pkg.utils import importutil
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."""
from langbot.pkg.utils import importutil
with pytest.raises((FileNotFoundError, Exception)):
importutil.read_resource_file_bytes("nonexistent/path/file.txt")
class TestListResourceFiles:
"""Test list_resource_files function."""
def test_lists_files_in_resource_directory(self):
"""Should list files in a resource directory."""
from langbot.pkg.utils import importutil
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."""
from langbot.pkg.utils import importutil
with pytest.raises((FileNotFoundError, Exception)):
importutil.list_resource_files("nonexistent_directory_xyz")
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+210
View File
@@ -0,0 +1,210 @@
"""
Unit tests for log cache utilities.
Tests log page management and pointer-based retrieval.
"""
from __future__ import annotations
from langbot.pkg.utils.logcache import LogPage, LogCache, LOG_PAGE_SIZE, MAX_CACHED_PAGES
class TestLogPage:
"""Tests for LogPage class."""
def test_init_creates_empty_page(self):
"""LogPage initializes with empty logs list."""
page = LogPage(number=0)
assert page.number == 0
assert page.logs == []
def test_add_log_appends_to_list(self):
"""add_log appends log to the list."""
page = LogPage(number=0)
page.add_log('log entry 1')
page.add_log('log entry 2')
assert len(page.logs) == 2
assert page.logs[0] == 'log entry 1'
assert page.logs[1] == 'log entry 2'
def test_add_log_returns_false_when_not_full(self):
"""add_log returns False when page is not full."""
page = LogPage(number=0)
for i in range(LOG_PAGE_SIZE - 1):
result = page.add_log(f'log {i}')
assert result is False
def test_add_log_returns_true_when_full(self):
"""add_log returns True when page reaches LOG_PAGE_SIZE."""
page = LogPage(number=0)
for i in range(LOG_PAGE_SIZE - 1):
page.add_log(f'log {i}')
result = page.add_log('last log')
assert result is True
def test_add_log_exactly_page_size(self):
"""Page contains exactly LOG_PAGE_SIZE logs when full."""
page = LogPage(number=0)
for i in range(LOG_PAGE_SIZE):
page.add_log(f'log {i}')
assert len(page.logs) == LOG_PAGE_SIZE
class TestLogCache:
"""Tests for LogCache class."""
def test_init_creates_first_page(self):
"""LogCache initializes with first empty page."""
cache = LogCache()
assert len(cache.log_pages) == 1
assert cache.log_pages[0].number == 0
assert cache.log_pages[0].logs == []
def test_add_log_to_first_page(self):
"""add_log adds to the first page initially."""
cache = LogCache()
cache.add_log('test log')
assert len(cache.log_pages) == 1
assert cache.log_pages[0].logs[0] == 'test log'
def test_add_log_creates_new_page_when_full(self):
"""add_log creates new page when current page is full."""
cache = LogCache()
# Fill first page
for i in range(LOG_PAGE_SIZE):
cache.add_log(f'log {i}')
# Add one more to trigger new page
cache.add_log('overflow log')
assert len(cache.log_pages) == 2
assert cache.log_pages[1].number == 1
assert cache.log_pages[1].logs[0] == 'overflow log'
def test_add_log_removes_oldest_page_when_exceeds_max(self):
"""Cache removes oldest page when exceeding MAX_CACHED_PAGES."""
cache = LogCache()
# Fill enough pages to exceed MAX_CACHED_PAGES
total_logs = (MAX_CACHED_PAGES + 1) * LOG_PAGE_SIZE
for i in range(total_logs):
cache.add_log(f'log {i}')
# Should have exactly MAX_CACHED_PAGES pages
assert len(cache.log_pages) == MAX_CACHED_PAGES
# First page should not be page 0
assert cache.log_pages[0].number > 0
def test_get_log_by_pointer_single_page(self):
"""get_log_by_pointer retrieves logs from single page."""
cache = LogCache()
cache.add_log('log 1')
cache.add_log('log 2')
cache.add_log('log 3')
result, page_num, offset = cache.get_log_by_pointer(0, 0)
assert 'log 1' in result
assert 'log 2' in result
assert 'log 3' in result
def test_get_log_by_pointer_with_offset(self):
"""get_log_by_pointer respects start offset."""
cache = LogCache()
cache.add_log('log 1')
cache.add_log('log 2')
cache.add_log('log 3')
result, page_num, offset = cache.get_log_by_pointer(0, 1)
assert 'log 1' not in result
assert 'log 2' in result
assert 'log 3' in result
def test_get_log_by_pointer_across_pages(self):
"""get_log_by_pointer retrieves logs across pages."""
cache = LogCache()
# Fill first page and add to second
for i in range(LOG_PAGE_SIZE):
cache.add_log(f'page0 log {i}')
cache.add_log('page1 log 0')
# Get from first page offset 0
result, page_num, offset = cache.get_log_by_pointer(0, 0)
# Should contain all logs from page 0 and page 1
assert 'page0 log 0' in result
assert 'page1 log 0' in result
def test_get_log_by_pointer_from_second_page(self):
"""get_log_by_pointer can start from second page."""
cache = LogCache()
# Fill first page and add to second
for i in range(LOG_PAGE_SIZE):
cache.add_log(f'page0 log {i}')
cache.add_log('page1 log 0')
# Get from second page
result, page_num, offset = cache.get_log_by_pointer(1, 0)
assert 'page0' not in result
assert 'page1 log 0' in result
def test_page_numbers_sequential(self):
"""Page numbers are sequential."""
cache = LogCache()
# Create multiple pages
for i in range(LOG_PAGE_SIZE * 3):
cache.add_log(f'log {i}')
for i, page in enumerate(cache.log_pages):
assert page.number == i
def test_empty_cache_get_log(self):
"""get_log_by_pointer works with empty cache."""
cache = LogCache()
result, page_num, offset = cache.get_log_by_pointer(0, 0)
assert result == ''
def test_get_log_by_pointer_nonexistent_page(self):
"""get_log_by_pointer handles nonexistent page number."""
cache = LogCache()
cache.add_log('log 1')
# Request page that doesn't exist
result, page_num, offset = cache.get_log_by_pointer(99, 0)
# Returns empty or last available
# Behavior depends on implementation
def test_max_cached_pages_constant(self):
"""MAX_CACHED_PAGES is defined and reasonable."""
assert MAX_CACHED_PAGES > 0
assert MAX_CACHED_PAGES <= 100 # Reasonable upper bound
def test_log_page_size_constant(self):
"""LOG_PAGE_SIZE is defined and reasonable."""
assert LOG_PAGE_SIZE > 0
assert LOG_PAGE_SIZE <= 1000 # Reasonable upper bound
+223
View File
@@ -0,0 +1,223 @@
"""
Tests for langbot.pkg.utils.paths module.
Tests path utility functions:
- get_frontend_path: locates frontend build files
- get_resource_path: locates resource files
- _check_if_source_install: detects source install mode
Uses tmp_path for file system isolation where applicable.
"""
import os
import pytest
from unittest.mock import patch
class TestCheckIfSourceInstall:
"""Test _check_if_source_install function."""
def test_returns_true_for_source_install(self, tmp_path, monkeypatch):
"""Should return True when main.py with LangBot marker exists."""
main_py = tmp_path / "main.py"
main_py.write_text('# LangBot/main.py\n# This is the entry point')
monkeypatch.chdir(tmp_path)
from langbot.pkg.utils import paths
paths._is_source_install = None
result = paths._check_if_source_install()
assert result is True
paths._is_source_install = None
def test_returns_false_when_no_main_py(self, tmp_path, monkeypatch):
"""Should return False when main.py doesn't exist."""
monkeypatch.chdir(tmp_path)
from langbot.pkg.utils import paths
paths._is_source_install = None
result = paths._check_if_source_install()
assert result is False
paths._is_source_install = None
def test_returns_false_when_main_py_without_marker(self, tmp_path, monkeypatch):
"""Should return False when main.py exists but lacks LangBot marker."""
main_py = tmp_path / "main.py"
main_py.write_text('# Some other project\nprint("hello")')
monkeypatch.chdir(tmp_path)
from langbot.pkg.utils import paths
paths._is_source_install = None
result = paths._check_if_source_install()
assert result is False
paths._is_source_install = None
def test_handles_io_error_gracefully(self, tmp_path, monkeypatch):
"""Should return False when main.py cannot be read."""
main_py = tmp_path / "main.py"
main_py.write_text('# LangBot/main.py\n')
monkeypatch.chdir(tmp_path)
from langbot.pkg.utils import paths
paths._is_source_install = None
# Patch open to raise IOError
with patch("builtins.open", side_effect=IOError("Cannot read")):
result = paths._check_if_source_install()
assert result is False
paths._is_source_install = None
class TestGetFrontendPath:
"""Test get_frontend_path function."""
def test_returns_web_dist_by_default(self):
"""Should return a path containing web/dist as default."""
from langbot.pkg.utils import paths
paths._is_source_install = None
result = paths.get_frontend_path()
# The result should contain web/dist or be an absolute path to it
assert "web/dist" in result or result.endswith("dist")
paths._is_source_install = None
def test_finds_dist_directory_in_source_mode(self, tmp_path, monkeypatch):
"""Should find web/dist when running from source mode."""
main_py = tmp_path / "main.py"
main_py.write_text('# LangBot/main.py\n')
web_dist = tmp_path / "web" / "dist"
web_dist.mkdir(parents=True)
monkeypatch.chdir(tmp_path)
from langbot.pkg.utils import paths
paths._is_source_install = None
result = paths.get_frontend_path()
assert result == "web/dist"
paths._is_source_install = None
def test_prefers_dist_over_out_in_source_mode(self, tmp_path, monkeypatch):
"""Should prefer web/dist over web/out when both exist in source mode."""
main_py = tmp_path / "main.py"
main_py.write_text('# LangBot/main.py\n')
web_dist = tmp_path / "web" / "dist"
web_dist.mkdir(parents=True)
web_out = tmp_path / "web" / "out"
web_out.mkdir(parents=True)
monkeypatch.chdir(tmp_path)
from langbot.pkg.utils import paths
paths._is_source_install = None
result = paths.get_frontend_path()
assert result == "web/dist"
paths._is_source_install = None
class TestGetResourcePath:
"""Test get_resource_path function."""
def test_returns_original_path_when_not_found(self, tmp_path, monkeypatch):
"""Should return original path when resource not found."""
monkeypatch.chdir(tmp_path)
from langbot.pkg.utils import paths
paths._is_source_install = None
result = paths.get_resource_path("nonexistent/file.txt")
assert result == "nonexistent/file.txt"
paths._is_source_install = None
def test_finds_resource_in_current_directory_source_mode(self, tmp_path, monkeypatch):
"""Should find resource in current directory when in source mode."""
main_py = tmp_path / "main.py"
main_py.write_text('# LangBot/main.py\n')
resource_file = tmp_path / "templates" / "config.yaml"
resource_file.parent.mkdir(parents=True, exist_ok=True)
resource_file.write_text("test: value")
monkeypatch.chdir(tmp_path)
from langbot.pkg.utils import paths
paths._is_source_install = None
result = paths.get_resource_path("templates/config.yaml")
assert os.path.exists(result)
paths._is_source_install = None
def test_returns_relative_path_in_source_mode(self, tmp_path, monkeypatch):
"""Should return relative path if resource exists in source mode."""
main_py = tmp_path / "main.py"
main_py.write_text('# LangBot/main.py\n')
resource_file = tmp_path / "test_resource.txt"
resource_file.write_text("test content")
monkeypatch.chdir(tmp_path)
from langbot.pkg.utils import paths
paths._is_source_install = None
result = paths.get_resource_path("test_resource.txt")
assert result == "test_resource.txt"
paths._is_source_install = None
class TestPathFunctionsCaching:
"""Test that path functions use caching correctly."""
def test_source_install_cache_is_used(self, tmp_path, monkeypatch):
"""_check_if_source_install should use cached result."""
main_py = tmp_path / "main.py"
main_py.write_text('# LangBot/main.py\n')
monkeypatch.chdir(tmp_path)
from langbot.pkg.utils import paths
paths._is_source_install = None
# First call sets cache
result1 = paths._check_if_source_install()
assert result1 is True
assert paths._is_source_install is True
# Second call uses cache (no file read needed)
result2 = paths._check_if_source_install()
assert result2 is True
paths._is_source_install = None
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+157
View File
@@ -0,0 +1,157 @@
"""
Unit tests for package manager utilities.
Tests pip command generation without actual installation.
"""
from __future__ import annotations
import inspect
from unittest.mock import patch
from langbot.pkg.utils import pkgmgr
class TestPkgMgr:
"""Tests for package manager functions."""
def test_install_calls_pipmain(self):
"""install calls pipmain with correct arguments."""
with patch('langbot.pkg.utils.pkgmgr.pipmain') as mock_pipmain:
pkgmgr.install('requests')
mock_pipmain.assert_called_once_with(['install', 'requests'])
def test_install_with_version(self):
"""install handles package with version specifier."""
with patch('langbot.pkg.utils.pkgmgr.pipmain') as mock_pipmain:
pkgmgr.install('requests>=2.0.0')
mock_pipmain.assert_called_once_with(['install', 'requests>=2.0.0'])
def test_install_upgrade_calls_pipmain(self):
"""install_upgrade calls pipmain with upgrade and mirror."""
with patch('langbot.pkg.utils.pkgmgr.pipmain') as mock_pipmain:
pkgmgr.install_upgrade('requests')
expected_args = [
'install',
'--upgrade',
'requests',
'-i',
'https://pypi.tuna.tsinghua.edu.cn/simple',
'--trusted-host',
'pypi.tuna.tsinghua.edu.cn',
]
mock_pipmain.assert_called_once_with(expected_args)
def test_run_pip_with_params(self):
"""run_pip passes params to pipmain."""
with patch('langbot.pkg.utils.pkgmgr.pipmain') as mock_pipmain:
pkgmgr.run_pip(['list', '--outdated'])
mock_pipmain.assert_called_once_with(['list', '--outdated'])
def test_run_pip_empty_params(self):
"""run_pip handles empty params."""
with patch('langbot.pkg.utils.pkgmgr.pipmain') as mock_pipmain:
pkgmgr.run_pip([])
mock_pipmain.assert_called_once_with([])
def test_install_requirements_calls_pipmain(self):
"""install_requirements calls pipmain with requirements file."""
with patch('langbot.pkg.utils.pkgmgr.pipmain') as mock_pipmain:
pkgmgr.install_requirements('requirements.txt')
expected_args = [
'install',
'-r',
'requirements.txt',
'-i',
'https://pypi.tuna.tsinghua.edu.cn/simple',
'--trusted-host',
'pypi.tuna.tsinghua.edu.cn',
]
mock_pipmain.assert_called_once_with(expected_args)
def test_install_requirements_defaults_extra_params_to_none(self):
"""install_requirements should not use a mutable default for extra_params."""
signature = inspect.signature(pkgmgr.install_requirements)
assert signature.parameters['extra_params'].default is None
def test_install_requirements_omitted_extra_params_uses_independent_base_commands(self, monkeypatch):
"""Omitted extra_params should not share mutable state across calls."""
calls = []
monkeypatch.setattr(pkgmgr, 'pipmain', calls.append)
pkgmgr.install_requirements('requirements.txt')
pkgmgr.install_requirements('requirements-dev.txt')
assert calls == [
[
'install',
'-r',
'requirements.txt',
'-i',
'https://pypi.tuna.tsinghua.edu.cn/simple',
'--trusted-host',
'pypi.tuna.tsinghua.edu.cn',
],
[
'install',
'-r',
'requirements-dev.txt',
'-i',
'https://pypi.tuna.tsinghua.edu.cn/simple',
'--trusted-host',
'pypi.tuna.tsinghua.edu.cn',
],
]
def test_install_requirements_preserves_explicit_extra_params(self, monkeypatch):
"""Explicit extra_params should be appended to the generated pip command."""
calls = []
monkeypatch.setattr(pkgmgr, 'pipmain', calls.append)
pkgmgr.install_requirements('requirements.txt', extra_params=['--no-deps'])
assert calls == [
[
'install',
'-r',
'requirements.txt',
'-i',
'https://pypi.tuna.tsinghua.edu.cn/simple',
'--trusted-host',
'pypi.tuna.tsinghua.edu.cn',
'--no-deps',
]
]
def test_install_requirements_with_extra_params(self):
"""install_requirements handles extra params."""
with patch('langbot.pkg.utils.pkgmgr.pipmain') as mock_pipmain:
pkgmgr.install_requirements('requirements.txt', ['--no-cache-dir'])
expected_args = [
'install',
'-r',
'requirements.txt',
'-i',
'https://pypi.tuna.tsinghua.edu.cn/simple',
'--trusted-host',
'pypi.tuna.tsinghua.edu.cn',
'--no-cache-dir',
]
mock_pipmain.assert_called_once_with(expected_args)
def test_install_requirements_multiple_extra_params(self):
"""install_requirements handles multiple extra params."""
with patch('langbot.pkg.utils.pkgmgr.pipmain') as mock_pipmain:
pkgmgr.install_requirements('requirements.txt', ['--no-cache-dir', '--verbose'])
call_args = mock_pipmain.call_args[0][0]
assert '--no-cache-dir' in call_args
assert '--verbose' in call_args
+89
View File
@@ -0,0 +1,89 @@
"""Unit tests for utils platform detection.
Tests cover:
- get_platform() function
- Docker environment detection
- WebSocket plugin runtime mode
"""
from __future__ import annotations
import os
import sys
from unittest.mock import patch
from importlib import import_module
def get_platform_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.utils.platform')
class TestGetPlatform:
"""Tests for get_platform function."""
def test_returns_docker_when_dockerenv_exists(self):
"""Test returns 'docker' when /.dockerenv file exists."""
platform_module = get_platform_module()
with patch('os.path.exists', return_value=True):
with patch.dict(os.environ, {}, clear=True):
result = platform_module.get_platform()
assert result == 'docker'
def test_returns_docker_when_env_var_true(self):
"""Test returns 'docker' when DOCKER_ENV=true."""
platform_module = get_platform_module()
with patch('os.path.exists', return_value=False):
with patch.dict(os.environ, {'DOCKER_ENV': 'true'}, clear=True):
result = platform_module.get_platform()
assert result == 'docker'
def test_returns_sys_platform_when_not_docker(self):
"""Test returns sys.platform when not in Docker."""
platform_module = get_platform_module()
with patch('os.path.exists', return_value=False):
with patch.dict(os.environ, {'DOCKER_ENV': 'false'}, clear=True):
result = platform_module.get_platform()
assert result == sys.platform
def test_returns_sys_platform_when_no_env_var(self):
"""Test returns sys.platform when DOCKER_ENV not set."""
platform_module = get_platform_module()
with patch('os.path.exists', return_value=False):
# Make sure DOCKER_ENV is not set
env_copy = os.environ.copy()
if 'DOCKER_ENV' in env_copy:
del env_copy['DOCKER_ENV']
with patch.dict(os.environ, env_copy, clear=True):
result = platform_module.get_platform()
assert result == sys.platform
def test_standalone_runtime_default_false(self):
"""Test standalone_runtime defaults to False."""
platform_module = get_platform_module()
# Check the module attribute
assert platform_module.standalone_runtime is False
def test_use_websocket_returns_standalone_runtime(self):
"""Test use_websocket_to_connect_plugin_runtime returns standalone_runtime."""
platform_module = get_platform_module()
result = platform_module.use_websocket_to_connect_plugin_runtime()
assert result == platform_module.standalone_runtime
def test_standalone_runtime_can_be_modified(self):
"""Test standalone_runtime can be modified."""
platform_module = get_platform_module()
original = platform_module.standalone_runtime
# Modify
platform_module.standalone_runtime = True
assert platform_module.use_websocket_to_connect_plugin_runtime() is True
# Restore
platform_module.standalone_runtime = original
+167
View File
@@ -0,0 +1,167 @@
"""
Unit tests for ProxyManager.
Tests proxy configuration from environment and config.
"""
from __future__ import annotations
import pytest
import os
from unittest.mock import Mock, patch
from langbot.pkg.utils.proxy import ProxyManager
pytestmark = pytest.mark.asyncio
class TestProxyManager:
"""Tests for ProxyManager class."""
def _create_mock_app(self, proxy_config: dict = None):
"""Create mock app with proxy config."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {'proxy': proxy_config or {}}
return mock_app
def test_init_creates_empty_proxies(self):
"""ProxyManager initializes with empty forward_proxies."""
mock_app = self._create_mock_app()
pm = ProxyManager(mock_app)
assert pm.forward_proxies == {}
async def test_initialize_reads_env_variables(self):
"""initialize reads HTTP_PROXY from environment."""
mock_app = self._create_mock_app()
with patch.dict(os.environ, {'HTTP_PROXY': 'http://env-proxy:8080', 'HTTPS_PROXY': 'https://env-proxy:8443'}):
pm = ProxyManager(mock_app)
await pm.initialize()
assert pm.forward_proxies['http://'] == 'http://env-proxy:8080'
assert pm.forward_proxies['https://'] == 'https://env-proxy:8443'
async def test_initialize_reads_lower_case_env(self):
"""initialize reads lower case http_proxy from environment."""
mock_app = self._create_mock_app()
with patch.dict(os.environ, {'http_proxy': 'http://lower-proxy:8080'}, clear=True):
# Clear HTTP_PROXY to test fallback
if 'HTTP_PROXY' in os.environ:
del os.environ['HTTP_PROXY']
pm = ProxyManager(mock_app)
await pm.initialize()
assert pm.forward_proxies['http://'] == 'http://lower-proxy:8080'
async def test_initialize_config_overrides_env(self):
"""Config proxy overrides environment variables."""
mock_app = self._create_mock_app(proxy_config={
'http': 'http://config-proxy:8080',
'https': 'https://config-proxy:8443',
})
with patch.dict(os.environ, {'HTTP_PROXY': 'http://env-proxy:8080'}):
pm = ProxyManager(mock_app)
await pm.initialize()
assert pm.forward_proxies['http://'] == 'http://config-proxy:8080'
assert pm.forward_proxies['https://'] == 'https://config-proxy:8443'
async def test_initialize_sets_env_variables(self):
"""initialize sets proxy to environment variables."""
mock_app = self._create_mock_app(proxy_config={
'http': 'http://test-proxy:8080',
'https': 'https://test-proxy:8443',
})
pm = ProxyManager(mock_app)
await pm.initialize()
assert os.environ.get('HTTP_PROXY') == 'http://test-proxy:8080'
assert os.environ.get('HTTPS_PROXY') == 'https://test-proxy:8443'
async def test_initialize_handles_empty_config(self):
"""initialize handles empty proxy config."""
mock_app = self._create_mock_app(proxy_config={})
with patch.dict(os.environ, clear=True):
pm = ProxyManager(mock_app)
await pm.initialize()
assert pm.forward_proxies['http://'] is None
assert pm.forward_proxies['https://'] is None
async def test_initialize_handles_no_env_no_config(self):
"""initialize handles no env and no config."""
mock_app = self._create_mock_app(proxy_config={})
# Clear proxy env vars
env_backup = {}
for key in ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy']:
env_backup[key] = os.environ.get(key)
if key in os.environ:
del os.environ[key]
try:
pm = ProxyManager(mock_app)
await pm.initialize()
assert pm.forward_proxies['http://'] is None
assert pm.forward_proxies['https://'] is None
finally:
# Restore env
for key, value in env_backup.items():
if value is not None:
os.environ[key] = value
def test_get_forward_proxies_returns_copy(self):
"""get_forward_proxies returns a copy of the dict."""
mock_app = self._create_mock_app()
pm = ProxyManager(mock_app)
pm.forward_proxies = {'http://': 'http://test:8080'}
result = pm.get_forward_proxies()
assert result == pm.forward_proxies
assert result is not pm.forward_proxies # Different object
def test_get_forward_proxies_modification_safe(self):
"""Modifying returned dict doesn't affect internal state."""
mock_app = self._create_mock_app()
pm = ProxyManager(mock_app)
pm.forward_proxies = {'http://': 'http://test:8080'}
result = pm.get_forward_proxies()
result['http://'] = 'http://modified:9999'
assert pm.forward_proxies['http://'] == 'http://test:8080'
async def test_initialize_http_only_config(self):
"""initialize handles http-only config."""
mock_app = self._create_mock_app(proxy_config={
'http': 'http://http-only:8080',
})
# Clear any existing proxy env vars
env_backup = {}
for key in ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy']:
env_backup[key] = os.environ.get(key)
if key in os.environ:
del os.environ[key]
try:
pm = ProxyManager(mock_app)
await pm.initialize()
assert pm.forward_proxies['http://'] == 'http://http-only:8080'
assert pm.forward_proxies['https://'] is None
finally:
# Restore env
for key, value in env_backup.items():
if value is not None:
os.environ[key] = value
+327
View File
@@ -0,0 +1,327 @@
"""
Tests for langbot.pkg.utils.runner module.
Tests runner category detection functions:
- get_runner_category: categorizes runner URLs as local, cloud, or unknown
- is_cloud_runner / is_local_runner: helper functions
- extract_runner_url: extracts URL from runner config
- get_runner_info: returns runner info dict
"""
import pytest
from unittest.mock import Mock, patch
from langbot.pkg.utils.runner import (
RunnerCategory,
CLOUD_DOMAINS,
LOCAL_PATTERNS,
get_runner_category,
get_runner_info,
is_cloud_runner,
is_local_runner,
extract_runner_url,
get_runner_category_from_runner,
)
class TestGetRunnerCategory:
"""Test runner category detection from URL."""
def test_empty_url_returns_unknown(self):
"""Empty or None URL should return UNKNOWN."""
assert get_runner_category("test", "") == RunnerCategory.UNKNOWN
assert get_runner_category("test", None) == RunnerCategory.UNKNOWN
def test_localhost_returns_local(self):
"""localhost URL should be categorized as LOCAL."""
assert get_runner_category("test", "http://localhost:3000") == RunnerCategory.LOCAL
assert get_runner_category("test", "https://localhost") == RunnerCategory.LOCAL
def test_127_0_0_1_returns_local(self):
"""127.0.0.1 URL should be categorized as LOCAL."""
assert get_runner_category("test", "http://127.0.0.1:8080") == RunnerCategory.LOCAL
assert get_runner_category("test", "https://127.0.0.1") == RunnerCategory.LOCAL
def test_0_0_0_0_returns_local(self):
"""0.0.0.0 URL should be categorized as LOCAL."""
assert get_runner_category("test", "http://0.0.0.0:8080") == RunnerCategory.LOCAL
def test_private_ip_192_168_returns_local(self):
"""192.168.x.x private IP should be categorized as LOCAL."""
assert get_runner_category("test", "http://192.168.1.1:3000") == RunnerCategory.LOCAL
assert get_runner_category("test", "http://192.168.0.100") == RunnerCategory.LOCAL
def test_private_ip_10_returns_local(self):
"""10.x.x.x private IP should be categorized as LOCAL."""
assert get_runner_category("test", "http://10.0.0.1:8080") == RunnerCategory.LOCAL
assert get_runner_category("test", "http://10.255.255.255") == RunnerCategory.LOCAL
def test_private_ip_172_16_31_returns_local(self):
"""172.16.x.x - 172.31.x.x private IP range should be categorized as LOCAL."""
assert get_runner_category("test", "http://172.16.0.1:8080") == RunnerCategory.LOCAL
assert get_runner_category("test", "http://172.20.0.1") == RunnerCategory.LOCAL
assert get_runner_category("test", "http://172.31.255.255") == RunnerCategory.LOCAL
def test_n8n_cloud_returns_cloud(self):
"""n8n.cloud domain should be categorized as CLOUD."""
assert get_runner_category("test", "https://myinstance.n8n.cloud") == RunnerCategory.CLOUD
assert get_runner_category("test", "https://test.n8n.io") == RunnerCategory.CLOUD
def test_dify_cloud_returns_cloud(self):
"""Dify cloud domains should be categorized as CLOUD."""
assert get_runner_category("test", "https://api.dify.ai/v1") == RunnerCategory.CLOUD
assert get_runner_category("test", "https://cloud.dify.ai") == RunnerCategory.CLOUD
def test_coze_cloud_returns_cloud(self):
"""Coze domains should be categorized as CLOUD."""
assert get_runner_category("test", "https://api.coze.com") == RunnerCategory.CLOUD
assert get_runner_category("test", "https://api.coze.cn") == RunnerCategory.CLOUD
def test_langflow_cloud_returns_cloud(self):
"""Langflow domains should be categorized as CLOUD."""
assert get_runner_category("test", "https://cloud.langflow.ai") == RunnerCategory.CLOUD
assert get_runner_category("test", "https://test.langflow.org") == RunnerCategory.CLOUD
def test_other_url_returns_cloud(self):
"""Other URLs should default to CLOUD category."""
assert get_runner_category("test", "https://example.com") == RunnerCategory.CLOUD
assert get_runner_category("test", "https://myserver.example.org") == RunnerCategory.CLOUD
@pytest.mark.parametrize(
'runner_url',
[
'api.dify.ai/v1',
'localhost:7860',
'https:///v1',
'https://',
'https://exa mple.com',
'http://[::1',
'http://localhost:bad',
],
)
def test_invalid_urls_return_unknown(self, runner_url):
"""Invalid or incomplete URLs should return UNKNOWN."""
assert get_runner_category("test", runner_url) == RunnerCategory.UNKNOWN
def test_urlparse_exception_returns_unknown(self):
"""Exception during URL parsing should return UNKNOWN."""
# Test by mocking urlparse to raise an exception
from langbot.pkg.utils import runner
def mock_urlparse(url):
raise Exception("URL parsing failed")
with patch("langbot.pkg.utils.runner.urlparse", side_effect=mock_urlparse):
result = runner.get_runner_category("test", "http://example.com")
assert result == RunnerCategory.UNKNOWN
def test_url_without_scheme_returns_unknown(self):
"""URL without scheme should return UNKNOWN."""
assert get_runner_category("test", "example.com") == RunnerCategory.UNKNOWN
@pytest.mark.parametrize(
'runner_url',
[
'http://localhost:7860',
'http://127.0.0.1:7860',
'http://10.0.0.1:7860',
'http://172.16.0.1:7860',
'http://172.31.255.255:7860',
'http://192.168.1.20:7860',
'http://[::1]:7860',
],
)
def test_detects_local_hosts_with_ipaddress(self, runner_url):
"""Local hostnames and private IPs should be categorized as LOCAL."""
assert get_runner_category('langflow-api', runner_url) == RunnerCategory.LOCAL
@pytest.mark.parametrize(
'runner_url',
[
'http://10.evil.com',
'http://192.168.example.com',
],
)
def test_private_ip_prefix_domains_are_not_local(self, runner_url):
"""Domain names that only look like private IP prefixes should not be LOCAL."""
assert get_runner_category('langflow-api', runner_url) == RunnerCategory.CLOUD
class TestIsCloudRunner:
"""Test is_cloud_runner helper function."""
def test_cloud_runner_returns_true(self):
"""Cloud URL should return True."""
assert is_cloud_runner("test", "https://api.dify.ai") is True
def test_local_runner_returns_false(self):
"""Local URL should return False."""
assert is_cloud_runner("test", "http://localhost:3000") is False
def test_unknown_returns_false(self):
"""Unknown category should return False."""
assert is_cloud_runner("test", None) is False
class TestIsLocalRunner:
"""Test is_local_runner helper function."""
def test_local_runner_returns_true(self):
"""Local URL should return True."""
assert is_local_runner("test", "http://localhost:3000") is True
def test_cloud_runner_returns_false(self):
"""Cloud URL should return False."""
assert is_local_runner("test", "https://api.dify.ai") is False
def test_unknown_returns_false(self):
"""Unknown category should return False."""
assert is_local_runner("test", None) is False
class TestGetRunnerInfo:
"""Test get_runner_info function."""
def test_returns_dict_with_expected_keys(self):
"""Should return dict with name, url, and category keys."""
info = get_runner_info("my-runner", "http://localhost:3000")
assert "name" in info
assert "url" in info
assert "category" in info
def test_includes_correct_values(self):
"""Should include correct values in dict."""
info = get_runner_info("my-runner", "http://localhost:3000")
assert info["name"] == "my-runner"
assert info["url"] == "http://localhost:3000"
assert info["category"] == RunnerCategory.LOCAL
class TestExtractRunnerUrl:
"""Test extract_runner_url function."""
def test_dify_service_api_extracts_url(self):
"""Should extract base-url from dify-service-api config."""
runner = Mock()
runner.pipeline_config = {}
pipeline_config = {
"ai": {
"dify-service-api": {"base-url": "https://api.dify.ai"}
}
}
url = extract_runner_url("dify-service-api", runner, pipeline_config)
assert url == "https://api.dify.ai"
def test_n8n_service_api_extracts_url(self):
"""Should extract webhook-url from n8n-service-api config."""
runner = Mock()
runner.pipeline_config = {}
pipeline_config = {
"ai": {
"n8n-service-api": {"webhook-url": "https://my.n8n.cloud/webhook"}
}
}
url = extract_runner_url("n8n-service-api", runner, pipeline_config)
assert url == "https://my.n8n.cloud/webhook"
def test_coze_api_extracts_url(self):
"""Should extract api-base from coze-api config."""
runner = Mock()
runner.pipeline_config = {}
pipeline_config = {
"ai": {
"coze-api": {"api-base": "https://api.coze.com"}
}
}
url = extract_runner_url("coze-api", runner, pipeline_config)
assert url == "https://api.coze.com"
def test_langflow_api_extracts_url(self):
"""Should extract base-url from langflow-api config."""
runner = Mock()
runner.pipeline_config = {}
pipeline_config = {
"ai": {
"langflow-api": {"base-url": "https://cloud.langflow.ai"}
}
}
url = extract_runner_url("langflow-api", runner, pipeline_config)
assert url == "https://cloud.langflow.ai"
def test_unknown_runner_returns_none(self):
"""Unknown runner name should return None."""
runner = Mock()
runner.pipeline_config = {}
pipeline_config = {}
url = extract_runner_url("unknown-runner", runner, pipeline_config)
assert url is None
def test_none_runner_returns_none(self):
"""None runner should return None."""
url = extract_runner_url("test", None, {})
assert url is None
def test_runner_without_pipeline_config_returns_none(self):
"""Runner without pipeline_config attribute should return None."""
runner = Mock(spec=[]) # Empty spec means no attributes
url = extract_runner_url("test", runner, {})
assert url is None
def test_none_pipeline_config_returns_none(self):
"""None pipeline_config should return None."""
runner = Mock()
runner.pipeline_config = {}
url = extract_runner_url("dify-service-api", runner, None)
assert url is None
def test_missing_ai_config_returns_none(self):
"""Missing ai config should return None."""
runner = Mock()
runner.pipeline_config = {}
pipeline_config = {}
url = extract_runner_url("dify-service-api", runner, pipeline_config)
assert url is None
class TestGetRunnerCategoryFromRunner:
"""Test get_runner_category_from_runner function."""
def test_extracts_and_categorizes(self):
"""Should extract URL and return correct category."""
runner = Mock()
runner.pipeline_config = {}
pipeline_config = {
"ai": {
"dify-service-api": {"base-url": "https://api.dify.ai"}
}
}
category = get_runner_category_from_runner("dify-service-api", runner, pipeline_config)
assert category == RunnerCategory.CLOUD
def test_returns_unknown_for_missing_url(self):
"""Should return UNKNOWN when URL cannot be extracted."""
runner = Mock()
runner.pipeline_config = {}
category = get_runner_category_from_runner("unknown", runner, {})
assert category == RunnerCategory.UNKNOWN
class TestConstants:
"""Test that constants are properly defined."""
def test_runner_category_constants(self):
"""RunnerCategory should have LOCAL, CLOUD, UNKNOWN."""
assert RunnerCategory.LOCAL == "local"
assert RunnerCategory.CLOUD == "cloud"
assert RunnerCategory.UNKNOWN == "unknown"
def test_cloud_domains_not_empty(self):
"""CLOUD_DOMAINS should not be empty."""
assert len(CLOUD_DOMAINS) > 0
def test_local_patterns_not_empty(self):
"""LOCAL_PATTERNS should not be empty."""
assert len(LOCAL_PATTERNS) > 0
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+136
View File
@@ -0,0 +1,136 @@
"""
Unit tests for version utility functions.
Tests version comparison logic without network calls.
"""
from __future__ import annotations
from unittest.mock import Mock
from langbot.pkg.utils.version import VersionManager
class TestVersionComparison:
"""Tests for version comparison functions."""
def _create_version_manager(self):
"""Create a VersionManager with mock app."""
mock_app = Mock()
mock_app.proxy_mgr = Mock()
mock_app.proxy_mgr.get_forward_providers = Mock(return_value={})
mock_app.logger = Mock()
return VersionManager(mock_app)
def test_is_newer_same_version(self):
"""is_newer returns False for same version."""
vm = self._create_version_manager()
result = vm.is_newer('v1.0.0', 'v1.0.0')
assert result is False
def test_is_newer_different_major_version(self):
"""is_newer returns False for different major version."""
# Note: is_newer ignores major version changes
vm = self._create_version_manager()
result = vm.is_newer('v2.0.0', 'v1.0.0')
assert result is False
def test_is_newer_minor_update(self):
"""is_newer returns True for minor update within same major."""
vm = self._create_version_manager()
result = vm.is_newer('v1.1.0', 'v1.0.0')
assert result is True
def test_is_newer_patch_update(self):
"""is_newer returns True for patch update within same major."""
vm = self._create_version_manager()
result = vm.is_newer('v1.0.1', 'v1.0.0')
assert result is True
def test_is_newer_with_fourth_segment(self):
"""is_newer ignores fourth version segment."""
# Both have same first 3 segments
vm = self._create_version_manager()
result = vm.is_newer('v1.0.0.1', 'v1.0.0.0')
assert result is False
def test_is_newer_short_version(self):
"""is_newer handles short version numbers."""
vm = self._create_version_manager()
result = vm.is_newer('v1.0', 'v1.0')
assert result is False
def test_is_newer_older_version(self):
"""is_newer returns True when new > old."""
vm = self._create_version_manager()
result = vm.is_newer('v1.2.0', 'v1.1.0')
assert result is True
class TestCompareVersionStr:
"""Tests for compare_version_str static method."""
def test_compare_equal_versions(self):
"""Equal versions return 0."""
result = VersionManager.compare_version_str('v1.0.0', 'v1.0.0')
assert result == 0
def test_compare_without_v_prefix(self):
"""Versions without v prefix work the same."""
result = VersionManager.compare_version_str('1.0.0', '1.0.0')
assert result == 0
def test_compare_mixed_prefix(self):
"""Mixed v prefix works correctly."""
result = VersionManager.compare_version_str('v1.0.0', '1.0.0')
assert result == 0
def test_compare_first_greater(self):
"""First version greater returns 1."""
result = VersionManager.compare_version_str('v1.1.0', 'v1.0.0')
assert result == 1
def test_compare_first_smaller(self):
"""First version smaller returns -1."""
result = VersionManager.compare_version_str('v1.0.0', 'v1.1.0')
assert result == -1
def test_compare_different_lengths(self):
"""Different length versions are padded with zeros."""
result = VersionManager.compare_version_str('v1.0', 'v1.0.0')
assert result == 0
def test_compare_shorter_greater(self):
"""Shorter version padded, first still greater."""
result = VersionManager.compare_version_str('v1.1', 'v1.0.0')
assert result == 1
def test_compare_longer_greater(self):
"""Longer version, first smaller."""
result = VersionManager.compare_version_str('v1.0', 'v1.0.1')
assert result == -1
def test_compare_major_version(self):
"""Major version comparison."""
result = VersionManager.compare_version_str('v2.0.0', 'v1.9.9')
assert result == 1
def test_compare_minor_version(self):
"""Minor version comparison."""
result = VersionManager.compare_version_str('v1.5.0', 'v1.4.9')
assert result == 1
def test_compare_patch_version(self):
"""Patch version comparison."""
result = VersionManager.compare_version_str('v1.0.1', 'v1.0.0')
assert result == 1
def test_compare_four_segments(self):
"""Four segment version comparison."""
result = VersionManager.compare_version_str('v1.0.0.1', 'v1.0.0.0')
assert result == 1
def test_compare_long_versions(self):
"""Long version strings work correctly."""
result = VersionManager.compare_version_str('v1.2.3.4.5', 'v1.2.3.4.4')
assert result == 1
View File
@@ -0,0 +1,210 @@
"""Tests for vector filter utilities."""
from __future__ import annotations
import pytest
from langbot.pkg.vector.filter_utils import (
SUPPORTED_OPS,
normalize_filter,
strip_unsupported_fields,
)
class TestNormalizeFilter:
"""Tests for normalize_filter function."""
def test_normalize_filter_empty_dict(self):
"""Empty dict returns empty list."""
result = normalize_filter({})
assert result == []
def test_normalize_filter_none(self):
"""None returns empty list."""
result = normalize_filter(None)
assert result == []
def test_normalize_filter_implicit_eq(self):
"""Bare value becomes implicit $eq."""
result = normalize_filter({'file_id': 'abc123'})
assert len(result) == 1
assert result[0] == ('file_id', '$eq', 'abc123')
def test_normalize_filter_explicit_eq(self):
"""Explicit $eq operator."""
result = normalize_filter({'file_id': {'$eq': 'abc123'}})
assert len(result) == 1
assert result[0] == ('file_id', '$eq', 'abc123')
def test_normalize_filter_comparison_operators(self):
"""Test comparison operators: $gt, $gte, $lt, $lte."""
result = normalize_filter({'created_at': {'$gte': 1700000000}})
assert len(result) == 1
assert result[0] == ('created_at', '$gte', 1700000000)
def test_normalize_filter_ne_operator(self):
"""Test $ne operator."""
result = normalize_filter({'status': {'$ne': 'deleted'}})
assert len(result) == 1
assert result[0] == ('status', '$ne', 'deleted')
def test_normalize_filter_in_operator(self):
"""Test $in operator with list value."""
result = normalize_filter({'file_type': {'$in': ['pdf', 'docx', 'txt']}})
assert len(result) == 1
assert result[0] == ('file_type', '$in', ['pdf', 'docx', 'txt'])
def test_normalize_filter_nin_operator(self):
"""Test $nin operator."""
result = normalize_filter({'status': {'$nin': ['deleted', 'archived']}})
assert len(result) == 1
assert result[0] == ('status', '$nin', ['deleted', 'archived'])
def test_normalize_filter_multiple_conditions(self):
"""Multiple top-level keys are AND-ed (returned as multiple triples)."""
result = normalize_filter({
'file_id': 'abc',
'status': {'$ne': 'deleted'},
'created_at': {'$gte': 1700000000}
})
assert len(result) == 3
# Order should match dict iteration order
field_ops = [(field, op) for field, op, _ in result]
assert ('file_id', '$eq') in field_ops
assert ('status', '$ne') in field_ops
assert ('created_at', '$gte') in field_ops
def test_normalize_filter_unsupported_operator_raises(self):
"""Unsupported operator raises ValueError."""
with pytest.raises(ValueError, match='Unsupported filter operator'):
normalize_filter({'field': {'$regex': 'pattern'}})
def test_normalize_filter_all_supported_ops(self):
"""Test all supported operators are recognized."""
for op in SUPPORTED_OPS:
if op in ('$in', '$nin'):
filter_dict = {'field': {op: ['value1', 'value2']}}
else:
filter_dict = {'field': {op: 'value'}}
result = normalize_filter(filter_dict)
assert len(result) == 1
assert result[0][1] == op
class TestStripUnsupportedFields:
"""Tests for strip_unsupported_fields function."""
def test_strip_keeps_supported_fields(self):
"""Fields in supported_fields are kept."""
triples = [
('file_id', '$eq', 'abc'),
('chunk_uuid', '$ne', 'def'),
]
result = strip_unsupported_fields(triples, {'file_id', 'chunk_uuid'})
assert len(result) == 2
assert result == triples
def test_strip_removes_unsupported_fields(self):
"""Fields not in supported_fields are removed."""
triples = [
('file_id', '$eq', 'abc'),
('unknown_field', '$ne', 'def'),
]
result = strip_unsupported_fields(triples, {'file_id'})
assert len(result) == 1
assert result[0] == ('file_id', '$eq', 'abc')
def test_strip_empty_triples(self):
"""Empty triples list returns empty list."""
result = strip_unsupported_fields([], {'file_id'})
assert result == []
def test_strip_all_unsupported(self):
"""All fields unsupported returns empty list."""
triples = [
('unknown1', '$eq', 'a'),
('unknown2', '$eq', 'b'),
]
result = strip_unsupported_fields(triples, {'file_id'})
assert result == []
def test_strip_with_field_aliases(self):
"""Field aliases are resolved before checking support."""
triples = [
('uuid', '$eq', 'abc'), # alias for chunk_uuid
('file_id', '$eq', 'def'),
]
result = strip_unsupported_fields(
triples,
{'file_id', 'chunk_uuid'},
field_aliases={'uuid': 'chunk_uuid'}
)
assert len(result) == 2
# 'uuid' should be resolved to 'chunk_uuid'
assert result[0] == ('chunk_uuid', '$eq', 'abc')
assert result[1] == ('file_id', '$eq', 'def')
def test_strip_alias_not_in_supported(self):
"""Alias resolved but still not in supported_fields is dropped."""
triples = [
('uuid', '$eq', 'abc'), # alias for chunk_uuid, but not supported
]
result = strip_unsupported_fields(
triples,
{'file_id'}, # chunk_uuid not supported
field_aliases={'uuid': 'chunk_uuid'}
)
assert result == []
def test_strip_preserves_operator_and_value(self):
"""Strip only affects field name, not operator or value."""
triples = [
('file_id', '$in', ['a', 'b', 'c']),
]
result = strip_unsupported_fields(triples, {'file_id'})
assert result[0] == ('file_id', '$in', ['a', 'b', 'c'])
def test_strip_none_aliases(self):
"""None field_aliases is treated as empty dict."""
triples = [
('file_id', '$eq', 'abc'),
]
result = strip_unsupported_fields(triples, {'file_id'}, field_aliases=None)
assert len(result) == 1
assert result[0] == ('file_id', '$eq', 'abc')
class TestSupportedOpsConstant:
"""Tests for SUPPORTED_OPS constant."""
def test_supported_ops_contains_expected(self):
"""SUPPORTED_OPS contains all expected operators."""
expected = {'$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in', '$nin'}
assert SUPPORTED_OPS == expected
def test_supported_ops_is_frozenset(self):
"""SUPPORTED_OPS is a frozenset for immutability."""
from collections.abc import Set
assert isinstance(SUPPORTED_OPS, Set)
+338
View File
@@ -0,0 +1,338 @@
"""Tests for VectorDBManager provider selection logic.
Tests the initialization logic that selects the appropriate VDB backend
based on configuration, without actually creating real VDB instances.
"""
from __future__ import annotations
from unittest.mock import MagicMock
from tests.utils.import_isolation import isolated_sys_modules
class TestVectorDBManagerInitialization:
"""Tests for VectorDBManager.initialize provider selection."""
def _create_mock_app(self, vdb_config: dict | None):
"""Create mock app with vdb configuration."""
mock_app = MagicMock()
mock_app.instance_config = MagicMock()
mock_app.instance_config.data = MagicMock()
mock_app.instance_config.data.get = MagicMock(return_value=vdb_config)
mock_app.logger = MagicMock()
mock_app.logger.info = MagicMock()
mock_app.logger.warning = MagicMock()
return mock_app
def _make_vector_import_mocks(self):
"""Create mocks for VDB backends to prevent real imports."""
mocks = {}
# Mock core.app to break circular import
mocks['langbot.pkg.core.app'] = MagicMock()
# Mock all VDB backend implementations
for backend in ['chroma', 'qdrant', 'seekdb', 'milvus', 'pgvector_db']:
mocks[f'langbot.pkg.vector.vdbs.{backend}'] = MagicMock()
return mocks
def test_initialize_no_config_defaults_to_chroma(self):
"""No vdb config defaults to Chroma."""
mock_app = self._create_mock_app(None)
mocks = self._make_vector_import_mocks()
# Create mock Chroma class
mock_chroma_class = MagicMock()
mocks['langbot.pkg.vector.vdbs.chroma'].ChromaVectorDatabase = mock_chroma_class
with isolated_sys_modules(mocks):
# Import after mocking
from langbot.pkg.vector.mgr import VectorDBManager
mgr = VectorDBManager(mock_app)
# Run initialize synchronously for test
import asyncio
asyncio.get_event_loop().run_until_complete(mgr.initialize())
# Chroma should be instantiated
mock_chroma_class.assert_called_once_with(mock_app)
mock_app.logger.warning.assert_called()
def test_initialize_chroma_backend(self):
"""Explicit chroma config uses Chroma backend."""
vdb_config = {'use': 'chroma'}
mock_app = self._create_mock_app(vdb_config)
mocks = self._make_vector_import_mocks()
mock_chroma_class = MagicMock()
mocks['langbot.pkg.vector.vdbs.chroma'].ChromaVectorDatabase = mock_chroma_class
with isolated_sys_modules(mocks):
from langbot.pkg.vector.mgr import VectorDBManager
mgr = VectorDBManager(mock_app)
import asyncio
asyncio.get_event_loop().run_until_complete(mgr.initialize())
mock_chroma_class.assert_called_once_with(mock_app)
mock_app.logger.info.assert_called()
def test_initialize_qdrant_backend(self):
"""Qdrant config uses Qdrant backend."""
vdb_config = {'use': 'qdrant'}
mock_app = self._create_mock_app(vdb_config)
mocks = self._make_vector_import_mocks()
mock_qdrant_class = MagicMock()
mocks['langbot.pkg.vector.vdbs.qdrant'].QdrantVectorDatabase = mock_qdrant_class
with isolated_sys_modules(mocks):
from langbot.pkg.vector.mgr import VectorDBManager
mgr = VectorDBManager(mock_app)
import asyncio
asyncio.get_event_loop().run_until_complete(mgr.initialize())
mock_qdrant_class.assert_called_once_with(mock_app)
def test_initialize_seekdb_backend(self):
"""SeekDB config uses SeekDB backend."""
vdb_config = {'use': 'seekdb'}
mock_app = self._create_mock_app(vdb_config)
mocks = self._make_vector_import_mocks()
mock_seekdb_class = MagicMock()
mocks['langbot.pkg.vector.vdbs.seekdb'].SeekDBVectorDatabase = mock_seekdb_class
with isolated_sys_modules(mocks):
from langbot.pkg.vector.mgr import VectorDBManager
mgr = VectorDBManager(mock_app)
import asyncio
asyncio.get_event_loop().run_until_complete(mgr.initialize())
mock_seekdb_class.assert_called_once_with(mock_app)
def test_initialize_milvus_backend_with_uri(self):
"""Milvus config with custom URI."""
vdb_config = {
'use': 'milvus',
'milvus': {
'uri': 'http://localhost:19530',
'token': 'root:Milvus',
'db_name': 'langbot_db'
}
}
mock_app = self._create_mock_app(vdb_config)
mocks = self._make_vector_import_mocks()
mock_milvus_class = MagicMock()
mocks['langbot.pkg.vector.vdbs.milvus'].MilvusVectorDatabase = mock_milvus_class
with isolated_sys_modules(mocks):
from langbot.pkg.vector.mgr import VectorDBManager
mgr = VectorDBManager(mock_app)
import asyncio
asyncio.get_event_loop().run_until_complete(mgr.initialize())
mock_milvus_class.assert_called_once_with(
mock_app,
uri='http://localhost:19530',
token='root:Milvus',
db_name='langbot_db'
)
def test_initialize_milvus_backend_defaults(self):
"""Milvus defaults when config not fully specified."""
vdb_config = {'use': 'milvus'}
mock_app = self._create_mock_app(vdb_config)
mocks = self._make_vector_import_mocks()
mock_milvus_class = MagicMock()
mocks['langbot.pkg.vector.vdbs.milvus'].MilvusVectorDatabase = mock_milvus_class
with isolated_sys_modules(mocks):
from langbot.pkg.vector.mgr import VectorDBManager
mgr = VectorDBManager(mock_app)
import asyncio
asyncio.get_event_loop().run_until_complete(mgr.initialize())
# Should use default values
mock_milvus_class.assert_called_once_with(
mock_app,
uri='./data/milvus.db',
token=None,
db_name='default'
)
def test_initialize_pgvector_with_connection_string(self):
"""pgvector with connection string."""
vdb_config = {
'use': 'pgvector',
'pgvector': {
'connection_string': 'postgresql://user:pass@host:5432/langbot'
}
}
mock_app = self._create_mock_app(vdb_config)
mocks = self._make_vector_import_mocks()
mock_pgvector_class = MagicMock()
mocks['langbot.pkg.vector.vdbs.pgvector_db'].PgVectorDatabase = mock_pgvector_class
with isolated_sys_modules(mocks):
from langbot.pkg.vector.mgr import VectorDBManager
mgr = VectorDBManager(mock_app)
import asyncio
asyncio.get_event_loop().run_until_complete(mgr.initialize())
mock_pgvector_class.assert_called_once_with(
mock_app,
connection_string='postgresql://user:pass@host:5432/langbot'
)
def test_initialize_pgvector_with_individual_params(self):
"""pgvector with individual connection parameters."""
vdb_config = {
'use': 'pgvector',
'pgvector': {
'host': 'db.example.com',
'port': 5433,
'database': 'vectordb',
'user': 'admin',
'password': 'secret'
}
}
mock_app = self._create_mock_app(vdb_config)
mocks = self._make_vector_import_mocks()
mock_pgvector_class = MagicMock()
mocks['langbot.pkg.vector.vdbs.pgvector_db'].PgVectorDatabase = mock_pgvector_class
with isolated_sys_modules(mocks):
from langbot.pkg.vector.mgr import VectorDBManager
mgr = VectorDBManager(mock_app)
import asyncio
asyncio.get_event_loop().run_until_complete(mgr.initialize())
mock_pgvector_class.assert_called_once_with(
mock_app,
host='db.example.com',
port=5433,
database='vectordb',
user='admin',
password='secret'
)
def test_initialize_pgvector_defaults(self):
"""pgvector defaults when no config params."""
vdb_config = {'use': 'pgvector'}
mock_app = self._create_mock_app(vdb_config)
mocks = self._make_vector_import_mocks()
mock_pgvector_class = MagicMock()
mocks['langbot.pkg.vector.vdbs.pgvector_db'].PgVectorDatabase = mock_pgvector_class
with isolated_sys_modules(mocks):
from langbot.pkg.vector.mgr import VectorDBManager
mgr = VectorDBManager(mock_app)
import asyncio
asyncio.get_event_loop().run_until_complete(mgr.initialize())
mock_pgvector_class.assert_called_once_with(
mock_app,
host='localhost',
port=5432,
database='langbot',
user='postgres',
password='postgres'
)
def test_initialize_unknown_backend_defaults_to_chroma(self):
"""Unknown vdb type defaults to Chroma with warning."""
vdb_config = {'use': 'unknown_backend'}
mock_app = self._create_mock_app(vdb_config)
mocks = self._make_vector_import_mocks()
mock_chroma_class = MagicMock()
mocks['langbot.pkg.vector.vdbs.chroma'].ChromaVectorDatabase = mock_chroma_class
with isolated_sys_modules(mocks):
from langbot.pkg.vector.mgr import VectorDBManager
mgr = VectorDBManager(mock_app)
import asyncio
asyncio.get_event_loop().run_until_complete(mgr.initialize())
mock_chroma_class.assert_called_once_with(mock_app)
mock_app.logger.warning.assert_called()
# Should warn about no valid backend
warning_msg = mock_app.logger.warning.call_args[0][0]
assert 'No valid' in warning_msg or 'defaulting' in warning_msg
class TestVectorDBManagerProxies:
"""Tests for VectorDBManager proxy methods."""
def test_get_supported_search_types_no_vector_db(self):
"""get_supported_search_types returns vector when no vector_db."""
mock_app = MagicMock()
mock_app.instance_config = MagicMock()
mock_app.instance_config.data = MagicMock()
mock_app.instance_config.data.get = MagicMock(return_value=None)
mock_app.logger = MagicMock()
mocks = {'langbot.pkg.core.app': MagicMock()}
for backend in ['chroma', 'qdrant', 'seekdb', 'milvus', 'pgvector_db']:
mocks[f'langbot.pkg.vector.vdbs.{backend}'] = MagicMock()
with isolated_sys_modules(mocks):
from langbot.pkg.vector.mgr import VectorDBManager
mgr = VectorDBManager(mock_app)
mgr.vector_db = None # Explicitly None
result = mgr.get_supported_search_types()
assert result == ['vector']
def test_get_supported_search_types_with_vector_db(self):
"""get_supported_search_types delegates to vector_db."""
mock_app = MagicMock()
# Create mock vector_db with supported_search_types
mock_vector_db = MagicMock()
mock_vector_db.supported_search_types = MagicMock(
return_value=[
MagicMock(value='vector'),
MagicMock(value='full_text'),
]
)
mocks = {'langbot.pkg.core.app': MagicMock()}
for backend in ['chroma', 'qdrant', 'seekdb', 'milvus', 'pgvector_db']:
mocks[f'langbot.pkg.vector.vdbs.{backend}'] = MagicMock()
with isolated_sys_modules(mocks):
from langbot.pkg.vector.mgr import VectorDBManager
mgr = VectorDBManager(mock_app)
mgr.vector_db = mock_vector_db
result = mgr.get_supported_search_types()
assert result == ['vector', 'full_text']

Some files were not shown because too many files have changed in this diff Show More