chore: import upstream snapshot with attribution
Test Migrations / Migrations (SQLite) (push) Has been cancelled
Build Dev Image / build-dev-image (push) Has been cancelled
Check i18n Keys / Check i18n Key Consistency (push) Has been cancelled
Lint / Ruff Lint & Format (push) Has been cancelled
Lint / Frontend Lint (push) Has been cancelled
Test Migrations / Migrations (PostgreSQL) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:44:22 +08:00
commit 534bb94eea
1288 changed files with 266913 additions and 0 deletions
+465
View File
@@ -0,0 +1,465 @@
# LangBot Test Suite
This directory contains the LangBot backend test suite, including unit tests,
integration tests, startup E2E tests, and container-backed Box runtime tests.
## Quality Gate Layers
LangBot uses a layered quality gate system for developers and CI:
| Layer | Command | What it runs | When to use |
|-------|---------|--------------|-------------|
| **Quick** | `make test-quick` or `bash scripts/test-quick.sh` | Ruff lint + Unit tests + Smoke tests | Before every commit |
| **Fast Integration** | `make test-integration-fast` or `bash scripts/test-integration-fast.sh` | SQLite/API/Pipeline integration (no external services) | Before PR, weekly |
| **Backend E2E** | `uv run --python 3.12 pytest tests/e2e -q --tb=short` | Starts a real LangBot process with minimal config | Before release, CI |
| **Box Integration** | `uv run --python 3.12 pytest tests/integration_tests -q --tb=short` | Real Box sandbox/runtime integration | Before Box/runtime changes, CI |
| **Frontend E2E** | `cd web && pnpm test:e2e` | Playwright smoke tests with mocked backend and Space APIs | Before web changes, CI |
| **Coverage Gate** | `make test-coverage` or `bash scripts/test-coverage.sh` | All tests with coverage, threshold: 18% | Before merge, CI |
| **Full Local** | `make test-all-local` | Quick + Integration + Coverage | Before major changes |
**Note**: PostgreSQL migration tests and slow tests are NOT in local default
gates. They run in separate CI workflows. Frontend Playwright tests live under
`web/tests/e2e` and are documented in `web/README.md`.
### Developer Workflow
```bash
# Daily: Quick self-test
bash scripts/test-quick.sh
# Before PR: Full local gate
make test-all-local
# Or run each layer separately:
bash scripts/test-quick.sh # ~2 min
bash scripts/test-integration-fast.sh # ~3 min
bash scripts/test-coverage.sh # ~8 min
uv run --python 3.12 pytest tests/e2e -q --tb=short
uv run --python 3.12 pytest tests/integration_tests -q --tb=short
cd web && pnpm test:e2e
```
### Coverage Baseline
Current coverage threshold: **18%**
Actual coverage: **30%**
This is a conservative baseline to prevent coverage regression. It does NOT represent the final quality target. Key modules have higher coverage:
- `pipeline.preproc.preproc`: 53%
- `pipeline.process.process`: 96%
- `pipeline.respback.respback`: 88%
- `telemetry.telemetry`: 87%
- `provider.session.sessionmgr`: 100%
- `provider.tools.toolmgr`: 83%
- `storage.providers.s3storage`: 80%
## Important Note
Due to circular import dependencies in the pipeline module structure, the test files use **lazy imports** via `importlib.import_module()` instead of direct imports. This ensures tests can run without triggering circular import errors.
## Structure
```
tests/
├── __init__.py
├── factories/ # Shared test factories
│ ├── __init__.py # Factory exports
│ ├── app.py # FakeApp factory
│ ├── message.py # Message/query factories
│ ├── provider.py # FakeProvider factory
│ └── platform.py # FakePlatform factory
├── integration/ # Integration tests (real resources)
│ ├── __init__.py
│ ├── api/ # HTTP API tests
│ │ ├── __init__.py
│ │ └── test_smoke.py # API smoke tests
│ ├── pipeline/ # Pipeline stage-chain tests
│ │ ├── __init__.py
│ │ └── test_full_flow.py # Full flow integration
│ └── persistence/ # Database/persistence tests
│ ├── __init__.py
│ └── test_migrations.py # Alembic migration tests
├── e2e/ # Real LangBot startup E2E tests
│ ├── conftest.py
│ ├── test_startup.py
│ └── utils/
├── integration_tests/ # Container-backed integration tests
│ └── box/ # Box runtime and MCP process tests
├── smoke/ # Smoke tests (quick validation)
│ └── test_fake_message_flow.py
├── unit_tests/ # Unit tests
│ ├── box/ # Box module tests
│ ├── config/ # Configuration tests
│ ├── pipeline/ # Pipeline stage tests
│ │ └── conftest.py # Shared fixtures and test infrastructure
│ ├── platform/ # Platform adapter tests
│ ├── plugin/ # Plugin system tests
│ │ └── test_handler_actions.py # Action handler tests
│ ├── provider/ # Provider tests
│ │ ├── test_session_manager.py # SessionManager tests
│ │ └── test_tool_manager.py # ToolManager tests
│ ├── rag/ # RAG tests
│ │ └── test_file_storage.py # File/ZIP storage tests
│ ├── storage/ # Storage tests
│ │ └── test_s3storage.py # S3StorageProvider tests
│ ├── vector/ # Vector tests
│ │ └── test_vdb_filter_conversion.py # VDB filter tests
│ └── telemetry/ # Telemetry tests (rewritten)
├── utils/ # Test utilities
│ ├── __init__.py
│ └── import_isolation.py # sys.modules isolation for circular imports
└── README.md # This file
```
## Test Factories
The `tests/factories/` package provides reusable test factories:
```python
from tests.factories import (
FakeApp, # Mock application
FakeProvider, # Fake LLM provider
FakePlatform, # Fake platform adapter
text_query, # Create text query
group_text_query, # Create group query
command_query, # Create command query
)
# Create fake app
app = FakeApp()
# Create query with text
query = text_query("hello world")
# Create fake provider that returns specific response
provider = FakeProvider().returns("test response")
# Create fake platform for outbound capture
platform = FakePlatform()
await platform.reply_message(query.message_event, reply_chain)
outbound = platform.get_outbound_messages()
```
See `tests/factories/__init__.py` for all available factories.
## Test Architecture
### Fixtures (`conftest.py`)
The test suite uses a centralized fixture system that provides:
- **MockApplication**: Comprehensive mock of the Application object with all dependencies
- **Mock objects**: Pre-configured mocks for Session, Conversation, Model, Adapter
- **Sample data**: Ready-to-use Query objects, message chains, and configurations
- **Helper functions**: Utilities for creating results and common assertions
### Design Principles
1. **Isolation**: Each test is independent and doesn't rely on external systems
2. **Mocking**: All external dependencies are mocked to ensure fast, reliable tests
3. **Coverage**: Tests cover happy paths, edge cases, and error conditions
4. **Extensibility**: Easy to add new tests by reusing existing fixtures
## Running Tests
### Quick self-test for developers
For local branch validation without real provider keys:
```bash
make test-quick
```
or
```bash
bash scripts/test-quick.sh
```
This runs:
1. Ruff lint check
2. Unit tests
3. Smoke tests
Suitable for quick validation before committing.
### Using the test runner script (recommended for full coverage)
```bash
bash run_tests.sh
```
This script automatically:
- Activates the virtual environment
- Installs test dependencies if needed
- Runs tests with coverage
- Generates HTML coverage report
### Manual test execution
#### Run all unit tests
```bash
uv run pytest tests/unit_tests/ --cov=langbot --cov-report=xml --cov-report=term
```
#### Run specific test module
```bash
uv run pytest tests/unit_tests/pipeline/ -v
```
#### Run specific test file
```bash
uv run pytest tests/unit_tests/pipeline/test_bansess.py -v
```
#### Run with coverage
```bash
uv run pytest tests/unit_tests/pipeline/ --cov=langbot --cov-report=html
```
#### Run specific test
```bash
uv run pytest tests/unit_tests/pipeline/test_bansess.py::test_bansess_whitelist_allow -v
```
### Using markers
```bash
# Run only unit tests
uv run pytest tests/unit_tests/ -m unit
# Run only integration tests
uv run pytest tests/integration/ -m integration
# Run integration tests excluding slow ones
uv run pytest tests/integration/ -m "not slow" -q
# Skip slow tests
uv run pytest tests/unit_tests/ -m "not slow"
```
### Running integration tests
Integration tests validate real system behavior with actual database/network resources.
```bash
# Run all integration tests (excluding slow ones)
uv run pytest tests/integration/ -m "not slow" -q
# Run SQLite migration integration tests
uv run pytest tests/integration/persistence/test_migrations.py -q --tb=short
# Run API smoke integration tests
uv run pytest tests/integration/api/test_smoke.py -q
# Run pipeline full-flow integration tests
uv run pytest tests/integration/pipeline/test_full_flow.py -q
# Run with verbose output
uv run pytest tests/integration/ -v
```
Note: Integration tests use:
- Temporary databases (tmp_path) for persistence tests
- Fake app/services for API tests (no real provider/platform)
- Fake runner/provider for pipeline tests (no real LLM API)
- Do not require external services
### Running migration tests locally
SQLite migration tests can be run locally without any external dependencies:
```bash
# SQLite migration tests (uses tmp_path, no external DB needed)
uv run pytest tests/integration/persistence/test_migrations.py -q --tb=short
```
PostgreSQL migration tests require an external PostgreSQL database:
```bash
# PostgreSQL migration tests (requires PostgreSQL service)
# Tests are marked as slow and skipped if TEST_POSTGRES_URL is not set
TEST_POSTGRES_URL=postgresql+asyncpg://user:pass@localhost:5432/test_db \
uv run pytest tests/integration/persistence/test_migrations_postgres.py -q --tb=short
# Or skip by default (no PostgreSQL available)
uv run pytest tests/integration/persistence/test_migrations_postgres.py -q --tb=short
# Output: SKIPPED (TEST_POSTGRES_URL not set)
```
Note: PostgreSQL tests are **not** included in fast integration gate because they:
- Require external PostgreSQL service
- Are marked with `@pytest.mark.slow`
- Need `TEST_POSTGRES_URL` environment variable
CI workflow `.github/workflows/test-migrations.yml` runs:
- SQLite tests in `test-migrations-sqlite` job (fast, no external services)
- PostgreSQL tests in `test-migrations-postgres` job (uses PostgreSQL service container)
### Running pipeline integration tests locally
Pipeline full-flow integration tests validate real stage interactions:
```bash
# Run pipeline integration tests (uses fake runner, no real LLM API)
uv run pytest tests/integration/pipeline/test_full_flow.py -q --tb=short
# Run with coverage for pipeline modules
uv run pytest tests/integration/pipeline \
--cov=langbot.pkg.pipeline.preproc.preproc \
--cov=langbot.pkg.pipeline.process.process \
--cov=langbot.pkg.pipeline.respback.respback \
--cov-report=term -q
```
These tests:
- Use `FakeRunner` class to simulate LLM responses without real API calls
- Import real `PreProcessor`, `MessageProcessor`, `SendResponseBackStage` stages
- Validate stage chain: PreProcessor → Processor → SendResponseBackStage
- Test prevent_default, exception handling, and full message flow
- Do not require real LLM provider keys
### Running backend E2E startup tests
Backend E2E tests start a real LangBot process with a generated minimal
`data/config.yaml`, SQLite database, local storage, and embedded Chroma path.
They do not require provider keys or external services.
```bash
uv run --python 3.12 pytest tests/e2e -q --tb=short
```
These tests verify startup orchestration, migrations, API route registration,
and the minimal no-LLM startup path. The E2E process manager disables ambient
proxy variables for subprocess startup and uses direct localhost HTTP clients,
so local proxy settings should not affect the health checks.
### Running Box integration tests
Box integration tests exercise the real sandbox runtime path, including command
execution, session persistence, managed process WebSocket attachment, and
cleanup behavior.
```bash
uv run --python 3.12 pytest tests/integration_tests -q --tb=short
```
These tests require a working Docker or Podman runtime. In CI, the dedicated
Box integration job checks Docker availability before running the tests.
### Running frontend E2E tests
Frontend E2E tests live in `web/tests/e2e` and use Playwright. They start Vite
and mock the LangBot backend and Space APIs, so no backend process is required.
```bash
cd web
pnpm test:e2e
```
### Known Issues
Some tests may encounter circular import errors. This is a known issue with the current module structure. The test infrastructure is designed to work around this using lazy imports, but if you encounter issues:
1. Make sure you're running from the project root directory
2. Ensure dependencies are installed: `uv sync --dev`
3. Try running a simple test first to verify the test infrastructure works
## CI/CD Integration
Tests are automatically run on:
- Pull request opened
- Pull request marked ready for review
- Push to PR branch
- Push to master/develop branches
The workflow runs tests on Python 3.11, 3.12, and 3.13 to ensure compatibility.
Startup E2E and Box integration tests run as separate Python 3.12 jobs because
they exercise process/container behavior instead of pure Python compatibility.
Frontend Playwright smoke tests run in `.github/workflows/frontend-tests.yml`.
## Adding New Tests
### 1. For a new pipeline stage
Create a new test file `test_<stage_name>.py`:
```python
"""
<StageName> stage unit tests
"""
import pytest
from langbot.pkg.pipeline.<module>.<stage> import <StageClass>
from langbot.pkg.pipeline import entities as pipeline_entities
@pytest.mark.asyncio
async def test_stage_basic_flow(mock_app, sample_query):
"""Test basic flow"""
stage = <StageClass>(mock_app)
await stage.initialize({})
result = await stage.process(sample_query, '<StageName>')
assert result.result_type == pipeline_entities.ResultType.CONTINUE
```
### 2. For additional fixtures
Add new fixtures to the appropriate `conftest.py`:
```python
@pytest.fixture
def my_custom_fixture():
"""Description of fixture"""
return create_test_data()
```
### 3. For test data
Use the helper functions in `conftest.py`:
```python
from tests.unit_tests.pipeline.conftest import create_stage_result, assert_result_continue
result = create_stage_result(
result_type=pipeline_entities.ResultType.CONTINUE,
query=sample_query
)
assert_result_continue(result)
```
## Best Practices
1. **Test naming**: Use descriptive names that explain what's being tested
2. **Arrange-Act-Assert**: Structure tests clearly with setup, execution, and verification
3. **One assertion per test**: Focus each test on a single behavior
4. **Mock appropriately**: Mock external dependencies, not the code under test
5. **Use fixtures**: Reuse common test data through fixtures
6. **Document tests**: Add docstrings explaining what each test validates
## Troubleshooting
### Import errors
Make sure you've installed the package in development mode:
```bash
uv sync --dev
```
### Async test failures
Ensure you're using `@pytest.mark.asyncio` decorator for async tests.
### Mock not working
Check that you're mocking at the right level and using `AsyncMock` for async functions.
## Future Enhancements
- [x] Add integration tests for database migrations (SQLite)
- [x] Add PostgreSQL migration integration tests (G-003)
- [x] Add integration tests for full pipeline execution
- [x] Add API smoke integration tests
- [ ] Add E2E tests
- [ ] Add performance benchmarks
- [ ] Add mutation testing for better coverage quality
- [ ] Add property-based testing with Hypothesis
View File
+102
View File
@@ -0,0 +1,102 @@
"""E2E test fixtures.
Provides fixtures for starting real LangBot process with minimal configuration.
"""
from __future__ import annotations
import pytest
import tempfile
import shutil
import logging
from pathlib import Path
from tests.e2e.utils.config_factory import create_minimal_config, create_test_directories
from tests.e2e.utils.process_manager import LangBotProcess, find_project_root
logger = logging.getLogger(__name__)
pytestmark = pytest.mark.e2e
@pytest.fixture(scope='session')
def e2e_port():
"""Port for E2E testing (non-default to avoid conflicts)."""
return 15300
@pytest.fixture(scope='session')
def e2e_tmpdir():
"""Create temporary directory for E2E testing."""
tmpdir = Path(tempfile.mkdtemp(prefix='langbot_e2e_'))
logger.info(f'E2E tmpdir: {tmpdir}')
yield tmpdir
# Cleanup
logger.info(f'Cleaning up E2E tmpdir: {tmpdir}')
shutil.rmtree(tmpdir, ignore_errors=True)
@pytest.fixture(scope='session')
def e2e_config_path(e2e_tmpdir, e2e_port):
"""Create minimal config.yaml for E2E testing."""
config_path = create_minimal_config(e2e_tmpdir, port=e2e_port)
create_test_directories(e2e_tmpdir)
logger.info(f'E2E config: {config_path}')
return config_path
@pytest.fixture(scope='session')
def langbot_process(e2e_config_path, e2e_port, e2e_tmpdir):
"""Start real LangBot process for E2E testing.
This fixture starts LangBot once per session and reuses it for all tests.
Coverage data is collected from the subprocess.
"""
project_root = find_project_root()
collect_coverage = True
proc = LangBotProcess(
project_root=project_root,
work_dir=e2e_tmpdir, # Run in tmpdir where data/config.yaml exists
port=e2e_port,
timeout=60, # Longer timeout for first startup
collect_coverage=collect_coverage,
)
success = proc.start()
if not success:
stdout, stderr = proc.get_logs()
pytest.fail(f'LangBot failed to start:\nstdout: {stdout}\nstderr: {stderr}')
yield proc
# Cleanup
proc.stop()
# Combine coverage data if collected
if collect_coverage and proc.get_coverage_file():
coverage_file = proc.get_coverage_file()
if coverage_file.exists():
# Copy coverage data to project root for combining
target = project_root / '.coverage.e2e'
shutil.copy(coverage_file, target)
logger.info(f'Coverage data saved to: {target}')
@pytest.fixture
def e2e_client(e2e_port, langbot_process):
"""HTTP client for E2E testing."""
import httpx
base_url = f'http://127.0.0.1:{e2e_port}'
with httpx.Client(base_url=base_url, timeout=10.0, trust_env=False) as client:
yield client
@pytest.fixture(scope='session')
def e2e_db_path(e2e_tmpdir):
"""Path to SQLite database file."""
return e2e_tmpdir / 'data' / 'langbot.db'
+147
View File
@@ -0,0 +1,147 @@
"""E2E tests for LangBot startup flow.
Tests the complete startup process including:
- boot.py startup orchestration
- stages/ (build_app, load_config, migrate, etc.)
- database initialization
- API availability
Run: uv run pytest tests/e2e/test_startup.py -v -m e2e
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.e2e
class TestStartupFlow:
"""Tests for LangBot startup process."""
def test_process_is_running(self, langbot_process):
"""Verify LangBot process is running."""
assert langbot_process.is_running()
def test_health_check(self, langbot_process, e2e_port):
"""Verify LangBot API is responding."""
assert langbot_process.health_check()
def test_system_info_endpoint(self, e2e_client):
"""Test /api/v1/system/info endpoint."""
response = e2e_client.get('/api/v1/system/info')
assert response.status_code == 200
data = response.json()
assert data['code'] == 0
assert 'data' in data
# System info should contain version info
assert 'version' in data['data'] or 'edition' in data['data']
def test_database_initialized(self, langbot_process, e2e_db_path):
"""Verify SQLite database was created and initialized."""
assert e2e_db_path.exists()
# Database should have some tables after migration
import sqlite3
conn = sqlite3.connect(str(e2e_db_path))
cursor = conn.cursor()
# Check that core tables exist
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
tables = [row[0] for row in cursor.fetchall()]
# Core tables should be created by Alembic migrations
# Note: table names may differ (legacy_pipelines instead of pipelines)
expected_tables = ['legacy_pipelines', 'bots', 'model_providers', 'llm_models']
for table in expected_tables:
assert table in tables, f'Table {table} should exist. Available: {tables}'
conn.close()
def test_chroma_directory_created(self, e2e_tmpdir):
"""Verify Chroma vector database directory was created."""
chroma_path = e2e_tmpdir / 'chroma'
# Created by the E2E config factory before startup.
assert chroma_path.exists()
def test_pipelines_endpoint(self, e2e_client):
"""Test /api/v1/pipelines endpoint (requires auth)."""
# Without auth, should return 401
response = e2e_client.get('/api/v1/pipelines')
assert response.status_code == 401
def test_auth_endpoint(self, e2e_client, e2e_tmpdir):
"""Test auth endpoint."""
# First startup may allow initial setup
response = e2e_client.post(
'/api/v1/user/auth',
json={
'user': 'admin',
'password': 'admin',
},
)
# Response could be:
# - 200 if auth succeeds
# - 400 if credentials wrong
# - 401 if user not initialized
assert response.status_code in [200, 400, 401]
class TestStartupStages:
"""Tests that verify individual startup stages worked correctly."""
def test_config_loaded(self, e2e_client):
"""Verify config was loaded correctly by checking API port."""
# If API responds on e2e_port, config was loaded
assert e2e_client.get('/api/v1/system/info').status_code == 200
def test_migrations_applied(self, langbot_process, e2e_db_path):
"""Verify database migrations were applied."""
import sqlite3
conn = sqlite3.connect(str(e2e_db_path))
cursor = conn.cursor()
# Check alembic_version table exists and has version
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='alembic_version';")
result = cursor.fetchone()
assert result is not None, 'alembic_version table should exist'
cursor.execute('SELECT version_num FROM alembic_version;')
version = cursor.fetchone()
assert version is not None, 'Migration version should be set'
conn.close()
def test_http_controller_initialized(self, e2e_client):
"""Verify HTTP controller was initialized."""
# Multiple endpoints should be available
endpoints = [
'/api/v1/system/info',
'/api/v1/pipelines',
'/api/v1/provider/providers',
'/api/v1/platform/bots',
]
for endpoint in endpoints:
response = e2e_client.get(endpoint)
# Should get a real route response, even if auth is required.
assert response.status_code in [200, 401, 403], f'{endpoint} should be registered'
class TestMinimalStartupNoLLM:
"""Tests verifying LangBot can start without LLM providers."""
def test_api_available_without_llm(self, e2e_client):
"""API should be available even without LLM providers configured."""
response = e2e_client.get('/api/v1/system/info')
assert response.status_code == 200
def test_pipeline_metadata_available(self, e2e_client):
"""Pipeline metadata endpoint should work without LLM."""
# Requires auth, but endpoint should exist
response = e2e_client.get('/api/v1/pipelines/_/metadata')
assert response.status_code in [200, 401] # Not 404 or 500
+190
View File
@@ -0,0 +1,190 @@
"""E2E test configuration factory.
Generates minimal config.yaml for testing LangBot startup without external dependencies.
"""
from __future__ import annotations
import yaml
from pathlib import Path
def create_minimal_config(tmpdir: Path, port: int = 15300) -> Path:
"""Create minimal config.yaml for E2E testing.
Uses embedded databases (SQLite, Chroma) to avoid external dependencies.
Config is created at tmpdir/data/config.yaml (LangBot expects this location).
"""
# LangBot expects config at data/config.yaml
data_dir = tmpdir / 'data'
data_dir.mkdir(parents=True, exist_ok=True)
config = {
'admins': [],
'api': {
'port': port,
'webhook_prefix': f'http://127.0.0.1:{port}',
'extra_webhook_prefix': '',
},
'command': {
'enable': True,
'prefix': ['!', '!'],
'privilege': {},
},
'concurrency': {
'pipeline': 20,
'session': 1,
},
'proxy': {
'http': '',
'https': '',
},
'system': {
'instance_id': '',
'edition': 'community',
'recovery_key': '',
'allow_modify_login_info': True,
'disabled_adapters': [],
'limitation': {
'max_bots': -1,
'max_pipelines': -1,
'max_extensions': -1,
},
'task_retention': {
'completed_limit': 200,
},
'jwt': {
'expire': 604800,
'secret': 'e2e-test-secret-key',
},
},
'database': {
'use': 'sqlite',
'sqlite': {
'path': str(tmpdir / 'data' / 'langbot.db'),
},
'postgresql': {
'host': '127.0.0.1',
'port': 5432,
'user': 'postgres',
'password': 'postgres',
'database': 'postgres',
},
},
'vdb': {
'use': 'chroma', # Chroma is embedded, no external dependency
'chroma': {
'path': str(tmpdir / 'chroma'),
},
'qdrant': {
'url': '',
'host': 'localhost',
'port': 6333,
'api_key': '',
},
'seekdb': {
'mode': 'embedded',
'path': str(tmpdir / 'seekdb'),
'database': 'langbot',
'host': 'localhost',
'port': 2881,
'user': 'root',
'password': '',
'tenant': '',
},
'milvus': {
'uri': 'http://127.0.0.1:19530',
'token': '',
'db_name': '',
},
'pgvector': {
'host': '127.0.0.1',
'port': 5433,
'database': 'langbot',
'user': 'postgres',
'password': 'postgres',
},
'valkey_search': {
'host': 'localhost',
'port': 6379,
'db': 0,
'password': '',
'username': '',
'tls': False,
'index_algorithm': 'HNSW',
'distance_metric': 'COSINE',
'request_timeout': 5000,
},
},
'storage': {
'use': 'local',
'cleanup': {
'enabled': False, # Disable cleanup for tests
'check_interval_hours': 1,
'uploaded_file_retention_days': 7,
'log_retention_days': 3,
},
'local': {
'path': str(tmpdir / 'storage'),
},
's3': {
'endpoint_url': '',
'access_key_id': '',
'secret_access_key': '',
'region': 'us-east-1',
'bucket': 'langbot-storage',
},
},
'plugin': {
'enable': False, # Disable plugin system for minimal startup
'runtime_ws_url': '',
'enable_marketplace': False,
'display_plugin_debug_url': '',
'binary_storage': {
'max_value_bytes': 10485760,
},
},
'monitoring': {
'auto_cleanup': {
'enabled': False, # Disable cleanup for tests
'retention_days': 30,
'check_interval_hours': 1,
'delete_batch_size': 1000,
},
},
'space': {
'url': 'https://space.langbot.app',
'models_gateway_api_url': 'https://api.langbot.cloud/v1',
'oauth_authorize_url': 'https://space.langbot.app/auth/authorize',
'disable_models_service': True, # Disable external services
'disable_telemetry': True, # Disable telemetry for tests
},
'provider': {}, # Empty providers - minimal startup
'llm': [], # Empty LLM models
}
# Ensure data directory exists (LangBot expects config at data/config.yaml)
data_dir = tmpdir / 'data'
data_dir.mkdir(parents=True, exist_ok=True)
# Write config to data/config.yaml (LangBot's expected location)
config_path = data_dir / 'config.yaml'
with open(config_path, 'w', encoding='utf-8') as f:
yaml.dump(config, f, default_flow_style=False)
return config_path
def create_test_directories(tmpdir: Path) -> dict[str, Path]:
"""Create necessary directories for LangBot testing."""
directories = {
'data': tmpdir / 'data',
'logs': tmpdir / 'logs',
'storage': tmpdir / 'storage',
'chroma': tmpdir / 'chroma',
}
for path in directories.values():
path.mkdir(parents=True, exist_ok=True)
return directories
+221
View File
@@ -0,0 +1,221 @@
"""E2E test process manager.
Manages LangBot subprocess lifecycle for E2E testing.
"""
from __future__ import annotations
import subprocess
import time
import signal
import os
from pathlib import Path
from typing import Optional
import logging
logger = logging.getLogger(__name__)
class LangBotProcess:
"""Manages a LangBot subprocess for E2E testing."""
def __init__(
self,
project_root: Path,
work_dir: Path,
port: int = 15300,
timeout: int = 30,
collect_coverage: bool = True,
):
self.project_root = project_root
self.work_dir = work_dir # Directory containing data/config.yaml
self.port = port
self.timeout = timeout
self.collect_coverage = collect_coverage
self.process: Optional[subprocess.Popen] = None
self._stdout_data: bytes = b''
self._stderr_data: bytes = b''
self._coverage_file: Optional[Path] = None
def start(self) -> bool:
"""Start LangBot process and wait for it to be ready."""
import httpx
# Prepare environment
env = os.environ.copy()
env['PYTHONPATH'] = str(self.project_root / 'src')
for proxy_key in (
'HTTP_PROXY',
'HTTPS_PROXY',
'ALL_PROXY',
'http_proxy',
'https_proxy',
'all_proxy',
):
env.pop(proxy_key, None)
env['NO_PROXY'] = '127.0.0.1,localhost'
env['no_proxy'] = '127.0.0.1,localhost'
# Set API port via environment variable
env['API__PORT'] = str(self.port)
env['API__WEBHOOK_PREFIX'] = f'http://127.0.0.1:{self.port}'
# Disable telemetry
env['SPACE__DISABLE_TELEMETRY'] = 'true'
env['SPACE__DISABLE_MODELS_SERVICE'] = 'true'
# Build command
if self.collect_coverage:
# Use coverage.py to collect coverage data
# Set COVERAGE_PROCESS_START to enable coverage in subprocess
self._coverage_file = self.work_dir / '.coverage.e2e'
env['COVERAGE_PROCESS_START'] = str(self.project_root / '.coveragerc')
env['COVERAGE_FILE'] = str(self._coverage_file)
# Create .coveragerc for subprocess
coveragerc_content = """
[run]
source = langbot.pkg
parallel = True
data_file = {}
omit =
*/tests/*
*/test_*.py
[report]
precision = 2
""".format(str(self._coverage_file))
coveragerc_path = self.work_dir / '.coveragerc'
with open(coveragerc_path, 'w') as f:
f.write(coveragerc_content)
cmd = [
'coverage',
'run',
'--rcfile=' + str(coveragerc_path),
'-m',
'langbot',
]
else:
cmd = ['uv', 'run', 'python', '-m', 'langbot']
logger.info(f'Starting LangBot in: {self.work_dir}')
logger.info(f'Command: {cmd}')
# Start process (run in work_dir so it finds data/config.yaml)
self.process = subprocess.Popen(
cmd,
cwd=self.work_dir,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
preexec_fn=os.setsid if os.name != 'nt' else None,
)
# Wait for startup
start_time = time.time()
while time.time() - start_time < self.timeout:
# Check if process died
if self.process.poll() is not None:
self._stdout_data, self._stderr_data = self.process.communicate()
logger.error(f'LangBot process died: {self._stderr_data.decode()}')
return False
# Try to connect
try:
r = httpx.get(
f'http://127.0.0.1:{self.port}/api/v1/system/info',
timeout=2.0,
follow_redirects=False,
trust_env=False,
)
if r.status_code == 200:
logger.info(f'LangBot started successfully on port {self.port}')
return True
except (httpx.ConnectError, httpx.TimeoutException):
pass
time.sleep(1)
# Timeout
logger.error(f'LangBot startup timeout after {self.timeout}s')
self.stop()
return False
def stop(self) -> None:
"""Stop LangBot process gracefully."""
if self.process is None:
return
logger.info('Stopping LangBot process...')
# Try graceful shutdown first
if os.name != 'nt':
# Send SIGTERM to process group
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
else:
self.process.terminate()
# Wait for graceful shutdown
try:
self.process.wait(timeout=5)
logger.info('LangBot stopped gracefully')
except subprocess.TimeoutExpired:
# Force kill
logger.warning('Force killing LangBot process')
if os.name != 'nt':
os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
else:
self.process.kill()
self.process.wait()
# Collect output for debugging
if self.process.stdout or self.process.stderr:
self._stdout_data, self._stderr_data = self.process.communicate()
self.process = None
def is_running(self) -> bool:
"""Check if process is still running."""
return self.process is not None and self.process.poll() is None
def get_logs(self) -> tuple[str, str]:
"""Get stdout and stderr logs."""
stdout = self._stdout_data.decode('utf-8', errors='replace')
stderr = self._stderr_data.decode('utf-8', errors='replace')
return stdout, stderr
def get_coverage_file(self) -> Optional[Path]:
"""Get coverage data file path."""
return self._coverage_file
def health_check(self) -> bool:
"""Check if LangBot API is responding."""
import httpx
if not self.is_running():
return False
try:
r = httpx.get(
f'http://127.0.0.1:{self.port}/api/v1/system/info',
timeout=5.0,
follow_redirects=False,
trust_env=False,
)
return r.status_code == 200
except Exception:
return False
def find_project_root() -> Path:
"""Find LangBot project root directory."""
current = Path(__file__).resolve()
# Walk up until we find src/langbot
for parent in current.parents:
if (parent / 'src' / 'langbot').exists():
return parent
# Fallback to LangBot-test-build directory
return Path('/home/glwuy/langbot-app/LangBot-test-build')
+102
View File
@@ -0,0 +1,102 @@
"""
Shared test factories for LangBot tests.
Provides reusable factories for:
- Fake application (app.py)
- Messages and queries (message.py)
- Fake providers (provider.py)
- Fake platforms (platform.py)
Usage:
from tests.factories import FakeApp, text_query, FakeProvider
app = FakeApp()
query = text_query("hello")
provider = FakeProvider.returns("response")
"""
from tests.factories.app import FakeApp, fake_app
from tests.factories.message import (
text_chain,
group_text_chain,
mention_chain,
image_chain,
text_query,
group_text_query,
private_text_query,
command_query,
mention_query,
empty_query,
image_query,
file_query,
unsupported_query,
voice_query,
at_all_query,
query_with_session,
query_with_config,
friend_message_event,
group_message_event,
mock_adapter,
)
from tests.factories.provider import (
FakeProvider,
fake_provider,
fake_provider_pong,
fake_provider_timeout,
fake_provider_auth_error,
fake_provider_rate_limit,
fake_provider_malformed,
fake_model,
)
from tests.factories.platform import (
FakePlatform,
fake_platform,
fake_platform_with_streaming,
fake_platform_with_failure,
mock_platform_adapter,
)
__all__ = [
# App
'FakeApp',
'fake_app',
# Message chains
'text_chain',
'group_text_chain',
'mention_chain',
'image_chain',
# Message events
'friend_message_event',
'group_message_event',
# Mock adapters
'mock_adapter',
# Queries
'text_query',
'group_text_query',
'private_text_query',
'command_query',
'mention_query',
'empty_query',
'image_query',
'file_query',
'unsupported_query',
'voice_query',
'at_all_query',
'query_with_session',
'query_with_config',
# Provider
'FakeProvider',
'fake_provider',
'fake_provider_pong',
'fake_provider_timeout',
'fake_provider_auth_error',
'fake_provider_rate_limit',
'fake_provider_malformed',
'fake_model',
# Platform
'FakePlatform',
'fake_platform',
'fake_platform_with_streaming',
'fake_platform_with_failure',
'mock_platform_adapter',
]
+153
View File
@@ -0,0 +1,153 @@
"""
Fake application factory for tests.
Provides a mock Application object with all dependencies needed by pipeline stages.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, Mock
class FakeApp:
"""Mock Application object providing all basic dependencies needed by stages."""
def __init__(
self,
*,
command_prefix: list[str] = ['/', '!'],
command_enable: bool = True,
pipeline_concurrency: int = 10,
admins: list[str] | None = None,
**extra_attrs,
):
self.logger = self._create_mock_logger()
self.sess_mgr = self._create_mock_session_manager()
self.model_mgr = self._create_mock_model_manager()
self.tool_mgr = self._create_mock_tool_manager()
self.plugin_connector = self._create_mock_plugin_connector()
self.persistence_mgr = self._create_mock_persistence_manager()
self.query_pool = self._create_mock_query_pool()
self.instance_config = self._create_mock_instance_config(
command_prefix=command_prefix,
command_enable=command_enable,
pipeline_concurrency=pipeline_concurrency,
admins=admins or [],
)
self.task_mgr = self._create_mock_task_manager()
# Handler-specific optional attributes
self.telemetry = self._create_mock_telemetry()
self.survey = None
self.cmd_mgr = self._create_mock_cmd_mgr()
self.skill_mgr = self._create_mock_skill_mgr()
self.pipeline_service = self._create_mock_pipeline_service()
# Apply any extra attributes for specific test scenarios
for name, value in extra_attrs.items():
setattr(self, name, value)
# Captured outbound messages (for assertions)
self._outbound_messages: list = []
def _create_mock_logger(self):
logger = Mock()
logger.debug = Mock()
logger.info = Mock()
logger.error = Mock()
logger.warning = Mock()
return logger
def _create_mock_session_manager(self):
sess_mgr = AsyncMock()
sess_mgr.get_session = AsyncMock()
sess_mgr.get_conversation = AsyncMock()
return sess_mgr
def _create_mock_model_manager(self):
model_mgr = AsyncMock()
model_mgr.get_model_by_uuid = AsyncMock()
return model_mgr
def _create_mock_tool_manager(self):
tool_mgr = AsyncMock()
tool_mgr.get_all_tools = AsyncMock(return_value=[])
return tool_mgr
def _create_mock_plugin_connector(self):
plugin_connector = AsyncMock()
plugin_connector.emit_event = AsyncMock()
return plugin_connector
def _create_mock_persistence_manager(self):
persistence_mgr = AsyncMock()
persistence_mgr.execute_async = AsyncMock()
return persistence_mgr
def _create_mock_query_pool(self):
query_pool = Mock()
query_pool.cached_queries = {}
query_pool.queries = []
query_pool.condition = AsyncMock()
return query_pool
def _create_mock_instance_config(
self,
command_prefix: list[str],
command_enable: bool,
pipeline_concurrency: int,
admins: list[str],
):
instance_config = Mock()
instance_config.data = {
'command': {'prefix': command_prefix, 'enable': command_enable},
'concurrency': {'pipeline': pipeline_concurrency},
'admins': admins,
}
return instance_config
def _create_mock_task_manager(self):
task_mgr = Mock()
task_mgr.create_task = Mock()
return task_mgr
def _create_mock_telemetry(self):
telemetry = AsyncMock()
telemetry.start_send_task = AsyncMock()
return telemetry
def _create_mock_cmd_mgr(self):
cmd_mgr = AsyncMock()
cmd_mgr.execute = AsyncMock()
return cmd_mgr
def _create_mock_skill_mgr(self):
"""Mock SkillManager that returns no skill index addition by default."""
skill_mgr = Mock()
skill_mgr.skills = {}
skill_mgr.build_skill_aware_prompt_addition = Mock(return_value='')
skill_mgr.get_skill_index = Mock(return_value=[])
return skill_mgr
def _create_mock_pipeline_service(self):
"""Mock PipelineService.get_pipeline returning empty extensions prefs."""
pipeline_service = AsyncMock()
pipeline_service.get_pipeline = AsyncMock(return_value={'extensions_preferences': {}})
return pipeline_service
def capture_message(self, message):
"""Capture an outbound message for test assertions."""
self._outbound_messages.append(message)
def get_outbound_messages(self) -> list:
"""Get all captured outbound messages."""
return self._outbound_messages.copy()
def clear_outbound_messages(self):
"""Clear captured outbound messages."""
self._outbound_messages.clear()
def fake_app(**kwargs) -> FakeApp:
"""Create a FakeApp instance with optional overrides."""
return FakeApp(**kwargs)
+478
View File
@@ -0,0 +1,478 @@
"""
Message and query factories for tests.
Provides reusable factories for creating message chains, events, and query objects.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, Mock
import typing
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.entities.builtin.provider.session as provider_session
# Counter for generating unique IDs
_query_counter = 0
def _next_query_id() -> int:
"""Generate a unique query ID."""
global _query_counter
_query_counter += 1
return _query_counter
# ============== Message Chain Factories ==============
def text_chain(text: str = 'hello') -> platform_message.MessageChain:
"""Create a simple text message chain."""
return platform_message.MessageChain(
[
platform_message.Plain(text=text),
]
)
def group_text_chain(text: str = 'hello') -> platform_message.MessageChain:
"""Create a group text message chain (same as text_chain, context provided by event)."""
return text_chain(text)
def mention_chain(
text: str = 'hello',
target: typing.Union[int, str] = 12345,
) -> platform_message.MessageChain:
"""Create a message chain with @mention."""
return platform_message.MessageChain(
[
platform_message.At(target=target),
platform_message.Plain(text=f' {text}'),
]
)
def image_chain(
text: str = '',
url: str = 'https://example.com/image.png',
) -> platform_message.MessageChain:
"""Create a message chain with an image."""
components = []
if text:
components.append(platform_message.Plain(text=text))
components.append(platform_message.Image(url=url))
return platform_message.MessageChain(components)
def command_chain(
command: str = 'help',
prefix: str = '/',
) -> platform_message.MessageChain:
"""Create a command message chain."""
return platform_message.MessageChain(
[
platform_message.Plain(text=f'{prefix}{command}'),
]
)
# ============== Message Event Factories ==============
def friend_message_event(
message_chain: platform_message.MessageChain,
sender_id: typing.Union[int, str] = 12345,
nickname: str = 'TestUser',
) -> platform_events.FriendMessage:
"""Create a friend (private) message event."""
sender = platform_entities.Friend(
id=sender_id,
nickname=nickname,
remark=None,
)
return platform_events.FriendMessage(
type='FriendMessage',
sender=sender,
message_chain=message_chain,
time=1609459200,
)
def group_message_event(
message_chain: platform_message.MessageChain,
sender_id: typing.Union[int, str] = 12345,
sender_name: str = 'TestUser',
group_id: typing.Union[int, str] = 99999,
group_name: str = 'TestGroup',
) -> platform_events.GroupMessage:
"""Create a group message event."""
group = platform_entities.Group(
id=group_id,
name=group_name,
permission=platform_entities.Permission.Member,
)
sender = platform_entities.GroupMember(
id=sender_id,
member_name=sender_name,
permission=platform_entities.Permission.Member,
group=group,
)
return platform_events.GroupMessage(
type='GroupMessage',
sender=sender,
message_chain=message_chain,
time=1609459200,
)
# ============== Mock Adapter Factory ==============
def mock_adapter() -> Mock:
"""Create a mock platform adapter."""
adapter = AsyncMock()
adapter.is_stream_output_supported = AsyncMock(return_value=False)
adapter.reply_message = AsyncMock()
adapter.reply_message_chunk = AsyncMock()
return adapter
# ============== Query Factories ==============
def _base_query(
message_chain: platform_message.MessageChain,
message_event: platform_events.MessageEvent,
launcher_type: provider_session.LauncherTypes,
launcher_id: typing.Union[int, str],
sender_id: typing.Union[int, str],
adapter: Mock,
**overrides,
) -> pipeline_query.Query:
"""Create a base query with model_construct to bypass validation."""
query_id = _next_query_id()
base_data = {
'query_id': query_id,
'launcher_type': launcher_type,
'launcher_id': launcher_id,
'sender_id': sender_id,
'message_chain': message_chain,
'message_event': message_event,
'adapter': adapter,
'pipeline_uuid': 'test-pipeline-uuid',
'bot_uuid': 'test-bot-uuid',
'pipeline_config': {
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {
'model': {'primary': 'test-model-uuid', 'fallbacks': []},
'prompt': 'test-prompt',
},
},
'output': {'misc': {'at-sender': False, 'quote-origin': False}},
'trigger': {'misc': {'combine-quote-message': False}},
},
'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,
}
# Apply overrides
for key, value in overrides.items():
base_data[key] = value
return pipeline_query.Query.model_construct(**base_data)
def text_query(
text: str = 'hello',
sender_id: typing.Union[int, str] = 12345,
**overrides,
) -> pipeline_query.Query:
"""Create a basic text query (private chat)."""
chain = text_chain(text)
event = friend_message_event(chain, sender_id)
adapter = mock_adapter()
return _base_query(
message_chain=chain,
message_event=event,
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=sender_id,
sender_id=sender_id,
adapter=adapter,
**overrides,
)
def private_text_query(
text: str = 'hello',
sender_id: typing.Union[int, str] = 12345,
**overrides,
) -> pipeline_query.Query:
"""Create a private text query (alias for text_query)."""
return text_query(text, sender_id, **overrides)
def group_text_query(
text: str = 'hello',
sender_id: typing.Union[int, str] = 12345,
group_id: typing.Union[int, str] = 99999,
**overrides,
) -> pipeline_query.Query:
"""Create a group text query."""
chain = text_chain(text)
event = group_message_event(chain, sender_id, group_id=group_id)
adapter = mock_adapter()
return _base_query(
message_chain=chain,
message_event=event,
launcher_type=provider_session.LauncherTypes.GROUP,
launcher_id=group_id,
sender_id=sender_id,
adapter=adapter,
**overrides,
)
def command_query(
command: str = 'help',
prefix: str = '/',
sender_id: typing.Union[int, str] = 12345,
**overrides,
) -> pipeline_query.Query:
"""Create a command-like query."""
chain = command_chain(command, prefix)
event = friend_message_event(chain, sender_id)
adapter = mock_adapter()
return _base_query(
message_chain=chain,
message_event=event,
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=sender_id,
sender_id=sender_id,
adapter=adapter,
**overrides,
)
def mention_query(
text: str = 'hello',
target: typing.Union[int, str] = 12345,
sender_id: typing.Union[int, str] = 12345,
group_id: typing.Union[int, str] = 99999,
**overrides,
) -> pipeline_query.Query:
"""Create a mention-bot query (group chat with @mention)."""
chain = mention_chain(text, target)
event = group_message_event(chain, sender_id, group_id=group_id)
adapter = mock_adapter()
return _base_query(
message_chain=chain,
message_event=event,
launcher_type=provider_session.LauncherTypes.GROUP,
launcher_id=group_id,
sender_id=sender_id,
adapter=adapter,
**overrides,
)
def empty_query(**overrides) -> pipeline_query.Query:
"""Create an empty message query."""
chain = platform_message.MessageChain([])
event = friend_message_event(chain)
adapter = mock_adapter()
return _base_query(
message_chain=chain,
message_event=event,
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
adapter=adapter,
**overrides,
)
def image_query(
text: str = '',
url: str = 'https://example.com/image.png',
sender_id: typing.Union[int, str] = 12345,
**overrides,
) -> pipeline_query.Query:
"""Create an image query."""
chain = image_chain(text, url)
event = friend_message_event(chain, sender_id)
adapter = mock_adapter()
return _base_query(
message_chain=chain,
message_event=event,
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=sender_id,
sender_id=sender_id,
adapter=adapter,
**overrides,
)
def file_query(
url: str = 'https://example.com/document.pdf',
name: str = 'document.pdf',
text: str = '',
sender_id: typing.Union[int, str] = 12345,
**overrides,
) -> pipeline_query.Query:
"""Create a file attachment query."""
components = []
if text:
components.append(platform_message.Plain(text=text))
components.append(platform_message.File(url=url, name=name))
chain = platform_message.MessageChain(components)
event = friend_message_event(chain, sender_id)
adapter = mock_adapter()
return _base_query(
message_chain=chain,
message_event=event,
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=sender_id,
sender_id=sender_id,
adapter=adapter,
**overrides,
)
def unsupported_query(
unsupported_type: str = 'CustomComponent',
text: str = '',
sender_id: typing.Union[int, str] = 12345,
**overrides,
) -> pipeline_query.Query:
"""Create a query with unsupported/unknown message segment."""
components = []
if text:
components.append(platform_message.Plain(text=text))
# Use Unknown component for unsupported types
components.append(platform_message.Unknown(text=f'Unsupported: {unsupported_type}'))
chain = platform_message.MessageChain(components)
event = friend_message_event(chain, sender_id)
adapter = mock_adapter()
return _base_query(
message_chain=chain,
message_event=event,
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=sender_id,
sender_id=sender_id,
adapter=adapter,
**overrides,
)
def query_with_session(
text: str = 'hello',
sender_id: typing.Union[int, str] = 12345,
session: provider_session.Session = None,
**overrides,
) -> pipeline_query.Query:
"""Create a query with a session object.
If session is None, creates a default session with empty conversation.
"""
if session is None:
# Create a default session
session = provider_session.Session(
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=sender_id,
sender_id=sender_id,
use_prompt_name='default',
using_conversation=None,
conversations=[],
)
return text_query(text, sender_id, session=session, **overrides)
def query_with_config(
text: str = 'hello',
sender_id: typing.Union[int, str] = 12345,
pipeline_config: dict = None,
**overrides,
) -> pipeline_query.Query:
"""Create a query with custom pipeline configuration.
If pipeline_config is None, uses default config.
Useful for testing specific stage behaviors.
"""
if pipeline_config is None:
pipeline_config = {
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {
'model': {'primary': 'test-model-uuid', 'fallbacks': []},
'prompt': 'test-prompt',
},
},
'output': {'misc': {'at-sender': False, 'quote-origin': False}},
'trigger': {'misc': {'combine-quote-message': False}},
}
return text_query(text, sender_id, pipeline_config=pipeline_config, **overrides)
def voice_query(
url: str = 'https://example.com/audio.mp3',
sender_id: typing.Union[int, str] = 12345,
**overrides,
) -> pipeline_query.Query:
"""Create a voice/audio query."""
components = [
platform_message.Voice(url=url),
]
chain = platform_message.MessageChain(components)
event = friend_message_event(chain, sender_id)
adapter = mock_adapter()
return _base_query(
message_chain=chain,
message_event=event,
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=sender_id,
sender_id=sender_id,
adapter=adapter,
**overrides,
)
def at_all_query(
text: str = 'hello',
sender_id: typing.Union[int, str] = 12345,
group_id: typing.Union[int, str] = 99999,
**overrides,
) -> pipeline_query.Query:
"""Create a group query with @All mention."""
components = [
platform_message.AtAll(),
platform_message.Plain(text=f' {text}'),
]
chain = platform_message.MessageChain(components)
event = group_message_event(chain, sender_id, group_id=group_id)
adapter = mock_adapter()
return _base_query(
message_chain=chain,
message_event=event,
launcher_type=provider_session.LauncherTypes.GROUP,
launcher_id=group_id,
sender_id=sender_id,
adapter=adapter,
**overrides,
)
+342
View File
@@ -0,0 +1,342 @@
"""
Fake platform factory for tests.
Provides a fake platform adapter for tests that need inbound message injection
and outbound message capture.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, Mock
import typing
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
class FakePlatform:
"""Fake platform adapter for unit and integration tests.
Simulates platform behavior without real network calls:
- Inbound text message construction
- Group and private conversation identities
- Mention-bot flag
- Outbound text capture
- Outbound file/image capture
- Send failure simulation
Does not start real platform adapters.
Does not call IM platform SDKs.
"""
def __init__(
self,
*,
bot_account_id: str = 'test-bot',
stream_output_supported: bool = False,
raise_error: Exception = None,
):
self.bot_account_id = bot_account_id
self._stream_output_supported = stream_output_supported
self._raise_error = raise_error
# Captured outbound messages
self._outbound_messages: list[dict] = []
self._outbound_chunks: list[dict] = []
# Registered listeners
self._listeners: dict = {}
def raises(self, error: Exception) -> 'FakePlatform':
"""Configure platform to raise an error on send."""
self._raise_error = error
return self
def send_failure(self) -> 'FakePlatform':
"""Configure platform to simulate send failure."""
return self.raises(Exception('Platform send failure'))
def supports_streaming(self, supported: bool = True) -> 'FakePlatform':
"""Configure whether streaming output is supported."""
self._stream_output_supported = supported
return self
def get_outbound_messages(self) -> list[dict]:
"""Get all captured outbound messages for assertions."""
return self._outbound_messages.copy()
def get_outbound_chunks(self) -> list[dict]:
"""Get all captured outbound streaming chunks for assertions."""
return self._outbound_chunks.copy()
def clear_outbound(self):
"""Clear captured outbound messages."""
self._outbound_messages.clear()
self._outbound_chunks.clear()
def last_message(self) -> dict | None:
"""Get the last captured outbound message."""
return self._outbound_messages[-1] if self._outbound_messages else None
def last_chunk(self) -> dict | None:
"""Get the last captured streaming chunk."""
return self._outbound_chunks[-1] if self._outbound_chunks else None
# ============== Inbound Message Construction ==============
def create_friend_message(
self,
text: str,
sender_id: typing.Union[int, str] = 12345,
nickname: str = 'TestUser',
) -> platform_events.FriendMessage:
"""Create an inbound friend (private) message event."""
sender = platform_entities.Friend(
id=sender_id,
nickname=nickname,
remark=None,
)
chain = platform_message.MessageChain(
[
platform_message.Plain(text=text),
]
)
return platform_events.FriendMessage(
type='FriendMessage',
sender=sender,
message_chain=chain,
time=1609459200,
)
def create_group_message(
self,
text: str,
sender_id: typing.Union[int, str] = 12345,
sender_name: str = 'TestUser',
group_id: typing.Union[int, str] = 99999,
group_name: str = 'TestGroup',
mention_bot: bool = False,
) -> platform_events.GroupMessage:
"""Create an inbound group message event.
Args:
text: Message text content
sender_id: Sender user ID
sender_name: Sender display name
group_id: Group ID
group_name: Group name
mention_bot: If True, prepend @mention of bot account
"""
group = platform_entities.Group(
id=group_id,
name=group_name,
permission=platform_entities.Permission.Member,
)
sender = platform_entities.GroupMember(
id=sender_id,
member_name=sender_name,
permission=platform_entities.Permission.Member,
group=group,
)
# Build message chain with optional mention
components = []
if mention_bot:
components.append(platform_message.At(target=self.bot_account_id))
components.append(platform_message.Plain(text=' '))
components.append(platform_message.Plain(text=text))
chain = platform_message.MessageChain(components)
return platform_events.GroupMessage(
type='GroupMessage',
sender=sender,
message_chain=chain,
time=1609459200,
)
def create_image_message(
self,
url: str = 'https://example.com/image.png',
text: str = '',
sender_id: typing.Union[int, str] = 12345,
is_group: bool = False,
group_id: typing.Union[int, str] = 99999,
) -> platform_events.MessageEvent:
"""Create an inbound image message event."""
components = []
if text:
components.append(platform_message.Plain(text=text))
components.append(platform_message.Image(url=url))
chain = platform_message.MessageChain(components)
if is_group:
return self.create_group_message('', sender_id, group_id=group_id)
# Replace chain
else:
sender = platform_entities.Friend(id=sender_id, nickname='TestUser', remark=None)
return platform_events.FriendMessage(
type='FriendMessage',
sender=sender,
message_chain=chain,
time=1609459200,
)
# ============== Adapter Methods (Simulated) ==============
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain,
):
"""Simulate sending a message (captures for assertions)."""
if self._raise_error:
raise self._raise_error
self._outbound_messages.append(
{
'type': 'send',
'target_type': target_type,
'target_id': target_id,
'message': message,
}
)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
):
"""Simulate replying to a message (captures for assertions)."""
if self._raise_error:
raise self._raise_error
self._outbound_messages.append(
{
'type': 'reply',
'source_type': message_source.type,
'source': message_source,
'message': message,
'quote_origin': quote_origin,
}
)
async def reply_message_chunk(
self,
message_source: platform_events.MessageEvent,
bot_message: dict,
message: platform_message.MessageChain,
quote_origin: bool = False,
is_final: bool = False,
):
"""Simulate streaming reply (captures for assertions)."""
if self._raise_error:
raise self._raise_error
self._outbound_chunks.append(
{
'type': 'reply_chunk',
'source_type': message_source.type,
'source': message_source,
'bot_message': bot_message,
'message': message,
'quote_origin': quote_origin,
'is_final': is_final,
}
)
async def is_stream_output_supported(self) -> bool:
"""Return whether streaming output is supported."""
return self._stream_output_supported
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable,
):
"""Register an event listener (stores for simulation)."""
if event_type not in self._listeners:
self._listeners[event_type] = []
self._listeners[event_type].append(callback)
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable,
):
"""Unregister an event listener."""
if event_type in self._listeners:
self._listeners[event_type].remove(callback)
async def run_async(self):
"""Simulate running the adapter (does nothing)."""
pass
async def kill(self) -> bool:
"""Simulate killing the adapter."""
return True
async def is_muted(self, group_id: int) -> bool:
"""Simulate checking mute status."""
return False
async def create_message_card(
self,
message_id: typing.Type[str, int],
event: platform_events.MessageEvent,
) -> bool:
"""Simulate creating a message card."""
return False
# ============== Simulation Helpers ==============
async def simulate_inbound_event(
self,
event: platform_events.Event,
):
"""Simulate receiving an inbound event by calling registered listeners."""
listeners = self._listeners.get(type(event), [])
for callback in listeners:
await callback(event, self)
def fake_platform(
bot_account_id: str = 'test-bot',
stream_output_supported: bool = False,
) -> FakePlatform:
"""Create a FakePlatform instance."""
return FakePlatform(
bot_account_id=bot_account_id,
stream_output_supported=stream_output_supported,
)
def fake_platform_with_streaming() -> FakePlatform:
"""Create a FakePlatform that supports streaming output."""
return FakePlatform(stream_output_supported=True)
def fake_platform_with_failure() -> FakePlatform:
"""Create a FakePlatform that simulates send failure."""
return FakePlatform().send_failure()
# ============== Mock Adapter (for Query) ==============
def mock_platform_adapter(platform: FakePlatform = None) -> Mock:
"""Create a mock platform adapter using FakePlatform or a simple mock."""
if platform is None:
platform = FakePlatform()
adapter = Mock()
adapter.bot_account_id = platform.bot_account_id
adapter.reply_message = AsyncMock(side_effect=platform.reply_message)
adapter.reply_message_chunk = AsyncMock(side_effect=platform.reply_message_chunk)
adapter.send_message = AsyncMock(side_effect=platform.send_message)
adapter.is_stream_output_supported = AsyncMock(return_value=platform._stream_output_supported)
adapter._fake_platform = platform # Store for assertions
return adapter
+228
View File
@@ -0,0 +1,228 @@
"""
Fake provider factory for tests.
Provides a deterministic fake provider that simulates LLM responses without real API calls.
"""
from __future__ import annotations
from unittest.mock import Mock
import typing
import langbot_plugin.api.entities.builtin.provider.message as provider_message
class FakeProvider:
"""Deterministic fake provider for unit and integration tests.
Simulates various provider behaviors:
- Normal text response
- Streaming response
- Timeout error
- Auth error
- Rate-limit error
- Malformed response
Does not call real LLM vendors.
Does not require API keys.
"""
PONG_RESPONSE = 'LANGBOT_FAKE_PONG'
def __init__(
self,
*,
default_response: str = 'fake response',
streaming_chunks: list[str] = None,
raise_error: Exception = None,
captured_requests: list = None,
):
self._default_response = default_response
self._streaming_chunks = streaming_chunks or ['fake ', 'response']
self._raise_error = raise_error
self._captured_requests = captured_requests if captured_requests is not None else []
def returns(self, text: str) -> 'FakeProvider':
"""Configure provider to return a specific text response."""
self._default_response = text
self._streaming_chunks = [text]
return self
def returns_streaming(self, chunks: list[str]) -> 'FakeProvider':
"""Configure provider to return streaming chunks."""
self._streaming_chunks = chunks
self._default_response = ''.join(chunks)
return self
def raises(self, error: Exception) -> 'FakeProvider':
"""Configure provider to raise an error."""
self._raise_error = error
return self
def timeout(self) -> 'FakeProvider':
"""Configure provider to simulate timeout."""
return self.raises(TimeoutError('Provider timeout'))
def auth_error(self) -> 'FakeProvider':
"""Configure provider to simulate auth error."""
return self.raises(Exception('Invalid API key'))
def rate_limit(self) -> 'FakeProvider':
"""Configure provider to simulate rate limit."""
return self.raises(Exception('Rate limit exceeded'))
def malformed(self) -> 'FakeProvider':
"""Configure provider to simulate malformed response."""
self._default_response = None
return self
def get_captured_requests(self) -> list:
"""Get all captured request arguments for assertions."""
return self._captured_requests.copy()
def clear_captured_requests(self):
"""Clear captured requests."""
self._captured_requests.clear()
def _create_message(self, content: str) -> provider_message.Message:
"""Create a provider message from text content."""
return provider_message.Message(
role='assistant',
content=content,
)
def _create_chunk(
self,
content: str,
is_final: bool = False,
msg_sequence: int = 0,
) -> provider_message.MessageChunk:
"""Create a provider message chunk."""
return provider_message.MessageChunk(
role='assistant',
content=content,
is_final=is_final,
msg_sequence=msg_sequence,
)
async def invoke_llm(
self,
query,
model,
messages: list,
funcs: list,
extra_args: dict,
remove_think: bool = False,
) -> provider_message.Message:
"""Simulate non-streaming LLM invocation."""
# Capture request for assertions
self._captured_requests.append(
{
'query_id': query.query_id if query else None,
'model': model.model_entity.name if model and hasattr(model, 'model_entity') else None,
'messages': messages,
'funcs': funcs,
'extra_args': extra_args,
}
)
# Simulate error if configured
if self._raise_error:
raise self._raise_error
# Return response
if self._default_response is None:
# Malformed response
return provider_message.Message(role='assistant', content=None)
return self._create_message(self._default_response)
async def invoke_llm_stream(
self,
query,
model,
messages: list,
funcs: list,
extra_args: dict,
remove_think: bool = False,
) -> typing.AsyncGenerator[provider_message.MessageChunk, None]:
"""Simulate streaming LLM invocation."""
# Capture request for assertions
self._captured_requests.append(
{
'query_id': query.query_id if query else None,
'model': model.model_entity.name if model and hasattr(model, 'model_entity') else None,
'messages': messages,
'funcs': funcs,
'extra_args': extra_args,
'streaming': True,
}
)
# Simulate error if configured
if self._raise_error:
raise self._raise_error
# Yield chunks
for i, chunk in enumerate(self._streaming_chunks):
is_final = i == len(self._streaming_chunks) - 1
yield self._create_chunk(chunk, is_final=is_final, msg_sequence=i)
def fake_provider(
default_response: str = 'fake response',
) -> FakeProvider:
"""Create a FakeProvider with optional default response."""
return FakeProvider(default_response=default_response)
def fake_provider_pong() -> FakeProvider:
"""Create a FakeProvider that returns the pong response."""
return FakeProvider(default_response=FakeProvider.PONG_RESPONSE)
def fake_provider_timeout() -> FakeProvider:
"""Create a FakeProvider that simulates timeout."""
return FakeProvider().timeout()
def fake_provider_auth_error() -> FakeProvider:
"""Create a FakeProvider that simulates auth error."""
return FakeProvider().auth_error()
def fake_provider_rate_limit() -> FakeProvider:
"""Create a FakeProvider that simulates rate limit."""
return FakeProvider().rate_limit()
def fake_provider_malformed() -> FakeProvider:
"""Create a FakeProvider that simulates malformed response."""
return FakeProvider().malformed()
# ============== Mock Model Factory ==============
def fake_model(
*,
uuid: str = 'test-model-uuid',
name: str = 'test-model',
abilities: list[str] = None,
provider: FakeProvider = None,
) -> Mock:
"""Create a mock model with a fake provider."""
model = Mock()
model.model_entity = Mock()
model.model_entity.uuid = uuid
model.model_entity.name = name
model.model_entity.abilities = abilities or ['func_call', 'vision']
model.model_entity.extra_args = {}
# Attach fake provider
if provider is None:
provider = FakeProvider()
model.provider = provider
return model
+6
View File
@@ -0,0 +1,6 @@
"""
Integration tests package.
These tests validate real system behavior with actual database/network resources.
Run with: uv run pytest tests/integration/ -m "not slow" -q
"""
+5
View File
@@ -0,0 +1,5 @@
"""
API integration tests package.
Tests for HTTP API endpoints using Quart test client.
"""
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import pytest
def dedupe_preregistered_groups() -> None:
"""Keep API integration route registration isolated across test modules."""
from langbot.pkg.api.http.controller import group
seen: set[tuple[str, str]] = set()
unique_groups = []
for group_cls in group.preregistered_groups:
key = (group_cls.name, group_cls.path)
if key in seen:
continue
seen.add(key)
unique_groups.append(group_cls)
group.preregistered_groups[:] = unique_groups
@pytest.fixture(scope='module')
def http_controller_cls(mock_circular_import_chain):
"""Import HTTPController under each module's circular-import isolation."""
from langbot.pkg.api.http.controller.main import HTTPController
dedupe_preregistered_groups()
return HTTPController
+252
View File
@@ -0,0 +1,252 @@
"""
API integration tests for bot endpoints.
Tests real HTTP API behavior for bot management.
Run: uv run pytest tests/integration/api/test_bots.py -q
"""
from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from tests.factories import FakeApp
pytestmark = pytest.mark.integration
@pytest.fixture(scope='module')
def mock_circular_import_chain():
"""Break circular import chain for API controller."""
from tests.utils.import_isolation import isolated_sys_modules, MockLifecycleControlScope
class FakeMinimalApplication:
pass
mock_app = MagicMock()
mock_app.Application = FakeMinimalApplication
mock_entities = MagicMock()
mock_entities.LifecycleControlScope = MockLifecycleControlScope
clear = [
'langbot.pkg.api.http.controller.group',
'langbot.pkg.api.http.controller.groups',
'langbot.pkg.api.http.controller.groups.platform',
'langbot.pkg.api.http.controller.groups.platform.bots',
'langbot.pkg.api.http.controller.groups.platform.adapters',
'langbot.pkg.api.http.controller.main',
]
with isolated_sys_modules(
mocks={
'langbot.pkg.core.app': mock_app,
'langbot.pkg.core.entities': mock_entities,
},
clear=clear,
):
import langbot.pkg.api.http.controller.groups.platform.bots as _bots # noqa: E402, F401
yield
@pytest.fixture(scope='module')
def fake_bot_app():
"""Create FakeApp with bot services (module scope for reuse)."""
app = FakeApp()
app.instance_config.data.update(
{
'api': {'port': 5300},
'system': {'allow_modify_login_info': True, 'limitation': {}},
}
)
# Auth services
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
app.apikey_service = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
# Bot service
app.bot_service = Mock()
app.bot_service.get_bots = AsyncMock(
return_value=[
{
'uuid': 'test-bot-uuid',
'name': 'Test Bot',
'platform': 'telegram',
'pipeline_uuid': 'test-pipeline-uuid',
}
]
)
app.bot_service.get_runtime_bot_info = AsyncMock(
return_value={
'uuid': 'test-bot-uuid',
'name': 'Test Bot',
'platform': 'telegram',
'pipeline_uuid': 'test-pipeline-uuid',
'webhook_url': 'https://example.com/webhook/test-bot-uuid',
}
)
app.bot_service.create_bot = AsyncMock(return_value={'uuid': 'new-bot-uuid'})
app.bot_service.update_bot = AsyncMock(return_value={})
app.bot_service.delete_bot = AsyncMock()
app.bot_service.list_event_logs = AsyncMock(return_value=([{'uuid': 'log-1', 'message': 'test log'}], 1))
app.bot_service.send_message = AsyncMock()
# Platform manager
app.platform_mgr = Mock()
return app
@pytest.fixture(scope='module')
async def quart_test_client(fake_bot_app, http_controller_cls):
"""Create Quart test client (module scope to avoid route re-registration)."""
controller = http_controller_cls(fake_bot_app)
await controller.initialize()
client = controller.quart_app.test_client()
yield client
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestBotEndpoints:
"""Tests for /api/v1/platform/bots endpoints."""
@pytest.mark.asyncio
async def test_get_bots_success(self, quart_test_client):
"""GET /api/v1/platform/bots returns bot list."""
response = await quart_test_client.get('/api/v1/platform/bots', headers={'Authorization': 'Bearer test_token'})
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'data' in data
assert 'bots' in data['data']
@pytest.mark.asyncio
async def test_create_bot_success(self, quart_test_client):
"""POST /api/v1/platform/bots creates new bot."""
response = await quart_test_client.post(
'/api/v1/platform/bots',
headers={'Authorization': 'Bearer test_token'},
json={'name': 'New Bot', 'platform': 'telegram', 'pipeline_uuid': 'test-pipeline'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'uuid' in data['data']
@pytest.mark.asyncio
async def test_get_single_bot_success(self, quart_test_client):
"""GET /api/v1/platform/bots/{uuid} returns bot with runtime info."""
response = await quart_test_client.get(
'/api/v1/platform/bots/test-bot-uuid', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'bot' in data['data']
@pytest.mark.asyncio
async def test_update_bot_success(self, quart_test_client):
"""PUT /api/v1/platform/bots/{uuid} updates bot."""
response = await quart_test_client.put(
'/api/v1/platform/bots/test-bot-uuid',
headers={'Authorization': 'Bearer test_token'},
json={'name': 'Updated Bot'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
@pytest.mark.asyncio
async def test_delete_bot_success(self, quart_test_client):
"""DELETE /api/v1/platform/bots/{uuid} deletes bot."""
response = await quart_test_client.delete(
'/api/v1/platform/bots/test-bot-uuid', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestBotLogsEndpoint:
"""Tests for bot logs endpoint."""
@pytest.mark.asyncio
async def test_get_bot_logs_success(self, quart_test_client):
"""POST /api/v1/platform/bots/{uuid}/logs returns logs."""
response = await quart_test_client.post(
'/api/v1/platform/bots/test-bot-uuid/logs',
headers={'Authorization': 'Bearer test_token'},
json={'from_index': -1, 'max_count': 10},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'logs' in data['data']
assert 'total_count' in data['data']
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestBotSendMessageEndpoint:
"""Tests for bot send message endpoint."""
@pytest.mark.asyncio
async def test_send_message_success(self, quart_test_client):
"""POST /api/v1/platform/bots/{uuid}/send_message sends message."""
response = await quart_test_client.post(
'/api/v1/platform/bots/test-bot-uuid/send_message',
headers={'Authorization': 'Bearer test_api_key'},
json={
'target_type': 'person',
'target_id': 'user123',
'message_chain': [{'type': 'text', 'text': 'Hello'}],
},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data']['sent'] is True
@pytest.mark.asyncio
async def test_send_message_missing_target_type(self, quart_test_client):
"""POST send_message without target_type returns 400."""
response = await quart_test_client.post(
'/api/v1/platform/bots/test-bot-uuid/send_message',
headers={'Authorization': 'Bearer test_api_key'},
json={'target_id': 'user123', 'message_chain': [{'type': 'text', 'text': 'Hello'}]},
)
assert response.status_code == 400
data = await response.get_json()
assert data['code'] == -1
@pytest.mark.asyncio
async def test_send_message_invalid_target_type(self, quart_test_client):
"""POST send_message with invalid target_type returns 400."""
response = await quart_test_client.post(
'/api/v1/platform/bots/test-bot-uuid/send_message',
headers={'Authorization': 'Bearer test_api_key'},
json={
'target_type': 'invalid',
'target_id': 'user123',
'message_chain': [{'type': 'text', 'text': 'Hello'}],
},
)
assert response.status_code == 400
data = await response.get_json()
assert data['code'] == -1
+291
View File
@@ -0,0 +1,291 @@
"""
API integration tests for embed widget endpoints.
Tests real HTTP API behavior for embed widget functionality.
Run: uv run pytest tests/integration/api/test_embed.py -q
"""
from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from tests.factories import FakeApp
pytestmark = pytest.mark.integration
@pytest.fixture(scope='module')
def mock_circular_import_chain():
"""Break circular import chain for API controller."""
from tests.utils.import_isolation import isolated_sys_modules, MockLifecycleControlScope
class FakeMinimalApplication:
pass
mock_app = MagicMock()
mock_app.Application = FakeMinimalApplication
mock_entities = MagicMock()
mock_entities.LifecycleControlScope = MockLifecycleControlScope
clear = [
'langbot.pkg.api.http.controller.group',
'langbot.pkg.api.http.controller.groups',
'langbot.pkg.api.http.controller.groups.pipelines',
'langbot.pkg.api.http.controller.groups.pipelines.embed',
'langbot.pkg.api.http.controller.main',
]
with isolated_sys_modules(
mocks={
'langbot.pkg.core.app': mock_app,
'langbot.pkg.core.entities': mock_entities,
},
clear=clear,
):
import langbot.pkg.api.http.controller.groups.pipelines.embed as _embed # noqa: E402, F401
yield
@pytest.fixture(scope='module')
def fake_embed_app():
"""Create FakeApp with embed widget services (module scope)."""
app = FakeApp()
app.instance_config.data.update(
{
'api': {'port': 5300},
'system': {'allow_modify_login_info': True, 'limitation': {}},
}
)
# Create mock web_page_bot with valid UUID format
mock_bot_entity = Mock()
mock_bot_entity.uuid = 'a1b2c3d4-5678-90ab-cdef-123456789abc'
mock_bot_entity.adapter = 'web_page_bot'
mock_bot_entity.enable = True
mock_bot_entity.use_pipeline_uuid = 'test-pipeline-uuid'
mock_bot_entity.name = 'Test Web Bot'
mock_bot_entity.adapter_config = {
'turnstile_secret_key': '',
'turnstile_site_key': '',
'language': 'en_US',
'bubble_icon': 'logo',
}
mock_runtime_bot = Mock()
mock_runtime_bot.bot_entity = mock_bot_entity
# Platform manager with bots
app.platform_mgr = Mock()
app.platform_mgr.bots = [mock_runtime_bot]
# WebSocket proxy bot with adapter
mock_websocket_adapter = Mock()
mock_websocket_adapter.get_websocket_messages = Mock(return_value=[{'id': 'msg-1', 'content': 'test message'}])
mock_websocket_adapter.reset_session = Mock()
mock_websocket_adapter.handle_websocket_message = AsyncMock()
mock_ws_proxy_bot = Mock()
mock_ws_proxy_bot.adapter = mock_websocket_adapter
app.platform_mgr.websocket_proxy_bot = mock_ws_proxy_bot
# Monitoring service for feedback
app.monitoring_service = Mock()
app.monitoring_service.record_feedback = AsyncMock()
return app
@pytest.fixture(scope='module')
async def quart_test_client(fake_embed_app, http_controller_cls):
"""Create Quart test client (module scope)."""
controller = http_controller_cls(fake_embed_app)
await controller.initialize()
client = controller.quart_app.test_client()
yield client
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestEmbedWidgetEndpoint:
"""Tests for widget.js endpoint."""
@pytest.mark.asyncio
async def test_get_widget_js_success(self, quart_test_client):
"""GET /api/v1/embed/{bot_uuid}/widget.js returns JS."""
response = await quart_test_client.get('/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/widget.js')
assert response.status_code == 200
assert 'javascript' in response.content_type
@pytest.mark.asyncio
async def test_get_widget_js_invalid_uuid(self, quart_test_client):
"""GET widget.js with invalid UUID returns 400."""
response = await quart_test_client.get('/api/v1/embed/invalid-uuid/widget.js')
assert response.status_code == 400
@pytest.mark.asyncio
async def test_get_widget_js_bot_not_found(self, quart_test_client):
"""GET widget.js for non-existent bot returns 404."""
response = await quart_test_client.get('/api/v1/embed/00000000-0000-0000-0000-000000000000/widget.js')
assert response.status_code == 404
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestEmbedLogoEndpoint:
"""Tests for logo endpoint."""
@pytest.mark.asyncio
async def test_get_logo_success(self, quart_test_client):
"""GET /api/v1/embed/logo returns image."""
response = await quart_test_client.get('/api/v1/embed/logo')
assert response.status_code == 200
assert 'image/webp' in response.content_type
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestEmbedTurnstileVerifyEndpoint:
"""Tests for Turnstile verification endpoint."""
@pytest.mark.asyncio
async def test_turnstile_verify_no_secret(self, quart_test_client):
"""POST turnstile verify without secret returns dummy token."""
response = await quart_test_client.post(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/turnstile/verify', json={'token': 'test-token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'token' in data['data']
@pytest.mark.asyncio
async def test_turnstile_verify_invalid_uuid(self, quart_test_client):
"""POST turnstile verify with invalid UUID returns 400."""
response = await quart_test_client.post(
'/api/v1/embed/invalid-uuid/turnstile/verify', json={'token': 'test-token'}
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_turnstile_verify_missing_token(self, quart_test_client):
"""POST turnstile verify without token returns 400."""
response = await quart_test_client.post(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/turnstile/verify', json={}
)
assert response.status_code == 400
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestEmbedMessagesEndpoint:
"""Tests for messages endpoint."""
@pytest.mark.asyncio
async def test_get_messages_person_success(self, quart_test_client):
"""GET messages/person returns messages."""
response = await quart_test_client.get(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/person',
headers={'Authorization': 'Bearer 1234567890.dummy'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'messages' in data['data']
@pytest.mark.asyncio
async def test_get_messages_group_success(self, quart_test_client):
"""GET messages/group returns messages."""
response = await quart_test_client.get(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/group',
headers={'Authorization': 'Bearer 1234567890.dummy'},
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_get_messages_invalid_session_type(self, quart_test_client):
"""GET messages with invalid session_type returns 400."""
response = await quart_test_client.get(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/invalid',
headers={'Authorization': 'Bearer 1234567890.dummy'},
)
assert response.status_code == 400
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestEmbedResetEndpoint:
"""Tests for session reset endpoint."""
@pytest.mark.asyncio
async def test_reset_session_person_success(self, quart_test_client):
"""POST reset/person resets session."""
response = await quart_test_client.post(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/reset/person',
headers={'Authorization': 'Bearer 1234567890.dummy'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
@pytest.mark.asyncio
async def test_reset_session_invalid_uuid(self, quart_test_client):
"""POST reset with invalid UUID returns 400."""
response = await quart_test_client.post(
'/api/v1/embed/invalid-uuid/reset/person', headers={'Authorization': 'Bearer 1234567890.dummy'}
)
assert response.status_code == 400
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestEmbedFeedbackEndpoint:
"""Tests for feedback submission endpoint."""
@pytest.mark.asyncio
async def test_submit_feedback_like(self, quart_test_client):
"""POST feedback with type=1 (like) succeeds."""
response = await quart_test_client.post(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/feedback',
headers={'Authorization': 'Bearer 1234567890.dummy'},
json={'message_id': 'msg-123', 'feedback_type': 1},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'feedback_id' in data['data']
@pytest.mark.asyncio
async def test_submit_feedback_dislike(self, quart_test_client):
"""POST feedback with type=2 (dislike) succeeds."""
response = await quart_test_client.post(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/feedback',
headers={'Authorization': 'Bearer 1234567890.dummy'},
json={'message_id': 'msg-123', 'feedback_type': 2},
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_submit_feedback_invalid_type(self, quart_test_client):
"""POST feedback with invalid type returns 400."""
response = await quart_test_client.post(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/feedback',
headers={'Authorization': 'Bearer 1234567890.dummy'},
json={'message_id': 'msg-123', 'feedback_type': 99},
)
assert response.status_code == 400
+257
View File
@@ -0,0 +1,257 @@
"""
API integration tests for knowledge base endpoints.
Tests real HTTP API behavior for knowledge base management.
Run: uv run pytest tests/integration/api/test_knowledge.py -q
"""
from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from tests.factories import FakeApp
pytestmark = pytest.mark.integration
@pytest.fixture(scope='module')
def mock_circular_import_chain():
"""Break circular import chain for API controller."""
from tests.utils.import_isolation import isolated_sys_modules, MockLifecycleControlScope
class FakeMinimalApplication:
pass
mock_app = MagicMock()
mock_app.Application = FakeMinimalApplication
mock_entities = MagicMock()
mock_entities.LifecycleControlScope = MockLifecycleControlScope
clear = [
'langbot.pkg.api.http.controller.group',
'langbot.pkg.api.http.controller.groups',
'langbot.pkg.api.http.controller.groups.knowledge',
'langbot.pkg.api.http.controller.groups.knowledge.base',
'langbot.pkg.api.http.controller.groups.knowledge.engines',
'langbot.pkg.api.http.controller.groups.knowledge.parsers',
'langbot.pkg.api.http.controller.main',
]
with isolated_sys_modules(
mocks={
'langbot.pkg.core.app': mock_app,
'langbot.pkg.core.entities': mock_entities,
},
clear=clear,
):
import langbot.pkg.api.http.controller.groups.knowledge.base as _knowledge # noqa: E402, F401
yield
@pytest.fixture(scope='module')
def fake_knowledge_app():
"""Create FakeApp with knowledge services (module scope for reuse)."""
app = FakeApp()
app.instance_config.data.update(
{
'api': {'port': 5300},
'system': {'allow_modify_login_info': True, 'limitation': {}},
}
)
# Auth services
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
app.apikey_service = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
# Knowledge service
app.knowledge_service = Mock()
app.knowledge_service.get_knowledge_bases = AsyncMock(
return_value=[
{
'uuid': 'test-kb-uuid',
'name': 'Test Knowledge Base',
'description': 'Test KB description',
'engine_plugin_id': 'test/engine',
'created_at': '2024-01-01T00:00:00',
'updated_at': '2024-01-01T00:00:00',
}
]
)
app.knowledge_service.get_knowledge_base = AsyncMock(
return_value={
'uuid': 'test-kb-uuid',
'name': 'Test Knowledge Base',
'description': 'Test KB description',
'engine_plugin_id': 'test/engine',
}
)
app.knowledge_service.create_knowledge_base = AsyncMock(return_value={'uuid': 'new-kb-uuid'})
app.knowledge_service.update_knowledge_base = AsyncMock(return_value={})
app.knowledge_service.delete_knowledge_base = AsyncMock()
app.knowledge_service.get_files_by_knowledge_base = AsyncMock(
return_value=[{'uuid': 'test-file-uuid', 'filename': 'test.pdf'}]
)
app.knowledge_service.store_file = AsyncMock(return_value={'task_id': 'test-task-id'})
app.knowledge_service.delete_file = AsyncMock()
app.knowledge_service.retrieve_knowledge_base = AsyncMock(return_value=[{'content': 'test result', 'score': 0.95}])
# RAG manager
app.rag_mgr = Mock()
return app
@pytest.fixture(scope='module')
async def quart_test_client(fake_knowledge_app, http_controller_cls):
"""Create Quart test client (module scope to avoid route re-registration)."""
controller = http_controller_cls(fake_knowledge_app)
await controller.initialize()
client = controller.quart_app.test_client()
yield client
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestKnowledgeBaseEndpoints:
"""Tests for /api/v1/knowledge/bases endpoints."""
@pytest.mark.asyncio
async def test_get_knowledge_bases_success(self, quart_test_client):
"""GET /api/v1/knowledge/bases returns knowledge base list."""
response = await quart_test_client.get(
'/api/v1/knowledge/bases', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'data' in data
assert 'bases' in data['data']
@pytest.mark.asyncio
async def test_create_knowledge_base_success(self, quart_test_client):
"""POST /api/v1/knowledge/bases creates new knowledge base."""
response = await quart_test_client.post(
'/api/v1/knowledge/bases',
headers={'Authorization': 'Bearer test_token'},
json={'name': 'New KB', 'engine_plugin_id': 'test/engine'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'uuid' in data['data']
@pytest.mark.asyncio
async def test_get_single_knowledge_base_success(self, quart_test_client):
"""GET /api/v1/knowledge/bases/{uuid} returns knowledge base."""
response = await quart_test_client.get(
'/api/v1/knowledge/bases/test-kb-uuid', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'base' in data['data']
@pytest.mark.asyncio
async def test_update_knowledge_base_success(self, quart_test_client):
"""PUT /api/v1/knowledge/bases/{uuid} updates knowledge base."""
response = await quart_test_client.put(
'/api/v1/knowledge/bases/test-kb-uuid',
headers={'Authorization': 'Bearer test_token'},
json={'name': 'Updated KB'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
@pytest.mark.asyncio
async def test_delete_knowledge_base_success(self, quart_test_client):
"""DELETE /api/v1/knowledge/bases/{uuid} deletes knowledge base."""
response = await quart_test_client.delete(
'/api/v1/knowledge/bases/test-kb-uuid', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestKnowledgeBaseFilesEndpoints:
"""Tests for knowledge base files endpoints."""
@pytest.mark.asyncio
async def test_get_files_success(self, quart_test_client):
"""GET /api/v1/knowledge/bases/{uuid}/files returns files."""
response = await quart_test_client.get(
'/api/v1/knowledge/bases/test-kb-uuid/files', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'files' in data['data']
@pytest.mark.asyncio
async def test_add_file_to_knowledge_base(self, quart_test_client):
"""POST /api/v1/knowledge/bases/{uuid}/files adds file."""
response = await quart_test_client.post(
'/api/v1/knowledge/bases/test-kb-uuid/files',
headers={'Authorization': 'Bearer test_token'},
json={'file_id': 'test-file-id', 'parser_plugin_id': 'test/parser'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'task_id' in data['data']
@pytest.mark.asyncio
async def test_delete_file_from_knowledge_base(self, quart_test_client):
"""DELETE /api/v1/knowledge/bases/{uuid}/files/{file_id}."""
response = await quart_test_client.delete(
'/api/v1/knowledge/bases/test-kb-uuid/files/test-file-uuid', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestKnowledgeBaseRetrieveEndpoint:
"""Tests for knowledge base retrieval endpoint."""
@pytest.mark.asyncio
async def test_retrieve_knowledge_success(self, quart_test_client):
"""POST /api/v1/knowledge/bases/{uuid}/retrieve."""
response = await quart_test_client.post(
'/api/v1/knowledge/bases/test-kb-uuid/retrieve',
headers={'Authorization': 'Bearer test_token'},
json={'query': 'test query', 'retrieval_settings': {'top_k': 5}},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'results' in data['data']
@pytest.mark.asyncio
async def test_retrieve_without_query_returns_error(self, quart_test_client):
"""POST retrieve without query returns 400."""
response = await quart_test_client.post(
'/api/v1/knowledge/bases/test-kb-uuid/retrieve', headers={'Authorization': 'Bearer test_token'}, json={}
)
assert response.status_code == 400
data = await response.get_json()
assert data['code'] == -1
+313
View File
@@ -0,0 +1,313 @@
"""
API integration tests for monitoring endpoints.
Tests real HTTP API behavior for monitoring data retrieval.
Run: uv run pytest tests/integration/api/test_monitoring.py -q
"""
from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from tests.factories import FakeApp
pytestmark = pytest.mark.integration
@pytest.fixture(scope='module')
def mock_circular_import_chain():
"""Break circular import chain for API controller."""
from tests.utils.import_isolation import isolated_sys_modules, MockLifecycleControlScope
class FakeMinimalApplication:
pass
mock_app = MagicMock()
mock_app.Application = FakeMinimalApplication
mock_entities = MagicMock()
mock_entities.LifecycleControlScope = MockLifecycleControlScope
clear = [
'langbot.pkg.api.http.controller.group',
'langbot.pkg.api.http.controller.groups',
'langbot.pkg.api.http.controller.groups.monitoring',
'langbot.pkg.api.http.controller.main',
]
with isolated_sys_modules(
mocks={
'langbot.pkg.core.app': mock_app,
'langbot.pkg.core.entities': mock_entities,
},
clear=clear,
):
import langbot.pkg.api.http.controller.groups.monitoring as _monitoring # noqa: E402, F401
yield
@pytest.fixture(scope='module')
def fake_monitoring_app():
"""Create FakeApp with monitoring services (module scope)."""
app = FakeApp()
app.instance_config.data.update(
{
'api': {'port': 5300},
'system': {'allow_modify_login_info': True, 'limitation': {}},
}
)
# Auth services - USER_TOKEN auth requires jwt verification AND get_user_by_email
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
# Monitoring service
app.monitoring_service = Mock()
app.monitoring_service.get_overview_metrics = AsyncMock(
return_value={
'total_messages': 100,
'total_llm_calls': 50,
'total_sessions': 20,
'active_sessions': 5,
'total_errors': 2,
}
)
app.monitoring_service.get_messages = AsyncMock(return_value=([{'id': 'msg-1', 'content': 'test'}], 100))
app.monitoring_service.get_llm_calls = AsyncMock(return_value=([{'id': 'llm-1'}], 50))
app.monitoring_service.get_tool_calls = AsyncMock(return_value=([{'id': 'tool-1'}], 5))
app.monitoring_service.get_embedding_calls = AsyncMock(return_value=([{'id': 'emb-1'}], 10))
app.monitoring_service.get_sessions = AsyncMock(return_value=([{'session_id': 'sess-1'}], 20))
app.monitoring_service.get_errors = AsyncMock(return_value=([{'id': 'err-1'}], 2))
app.monitoring_service.get_session_analysis = AsyncMock(
return_value={
'found': True,
'session_id': 'sess-1',
}
)
app.monitoring_service.get_message_details = AsyncMock(
return_value={
'found': True,
'message_id': 'msg-1',
}
)
app.monitoring_service.get_feedback_stats = AsyncMock(return_value={'like_count': 10})
app.monitoring_service.get_feedback_list = AsyncMock(return_value=([{'feedback_id': 'fb-1'}], 12))
app.monitoring_service.export_messages = AsyncMock(return_value=[{'id': 'msg-1'}])
app.monitoring_service.export_llm_calls = AsyncMock(return_value=[{'id': 'llm-1'}])
app.monitoring_service.export_errors = AsyncMock(return_value=[{'id': 'err-1'}])
app.monitoring_service.export_sessions = AsyncMock(return_value=[{'session_id': 'sess-1'}])
app.monitoring_service.export_feedback = AsyncMock(return_value=[{'id': 'fb-1'}])
app.monitoring_service.export_embedding_calls = AsyncMock(return_value=[{'id': 'emb-1'}])
app.monitoring_service._escape_csv_field = Mock(return_value='escaped')
return app
@pytest.fixture(scope='module')
async def quart_test_client(fake_monitoring_app, http_controller_cls):
"""Create Quart test client (module scope)."""
controller = http_controller_cls(fake_monitoring_app)
await controller.initialize()
client = controller.quart_app.test_client()
yield client
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringOverviewEndpoint:
"""Tests for /api/v1/monitoring/overview endpoint."""
@pytest.mark.asyncio
async def test_get_overview_success(self, quart_test_client):
"""GET /api/v1/monitoring/overview returns metrics."""
response = await quart_test_client.get(
'/api/v1/monitoring/overview', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringMessagesEndpoint:
"""Tests for /api/v1/monitoring/messages endpoint."""
@pytest.mark.asyncio
async def test_get_messages_success(self, quart_test_client):
"""GET /api/v1/monitoring/messages returns message list."""
response = await quart_test_client.get(
'/api/v1/monitoring/messages', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'messages' in data['data']
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringLLMCallsEndpoint:
"""Tests for /api/v1/monitoring/llm-calls endpoint."""
@pytest.mark.asyncio
async def test_get_llm_calls_success(self, quart_test_client):
"""GET /api/v1/monitoring/llm-calls."""
response = await quart_test_client.get(
'/api/v1/monitoring/llm-calls', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringEmbeddingCallsEndpoint:
"""Tests for /api/v1/monitoring/embedding-calls endpoint."""
@pytest.mark.asyncio
async def test_get_embedding_calls_success(self, quart_test_client):
"""GET /api/v1/monitoring/embedding-calls."""
response = await quart_test_client.get(
'/api/v1/monitoring/embedding-calls', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringSessionsEndpoint:
"""Tests for /api/v1/monitoring/sessions endpoint."""
@pytest.mark.asyncio
async def test_get_sessions_success(self, quart_test_client):
"""GET /api/v1/monitoring/sessions."""
response = await quart_test_client.get(
'/api/v1/monitoring/sessions', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringErrorsEndpoint:
"""Tests for /api/v1/monitoring/errors endpoint."""
@pytest.mark.asyncio
async def test_get_errors_success(self, quart_test_client):
"""GET /api/v1/monitoring/errors."""
response = await quart_test_client.get(
'/api/v1/monitoring/errors', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringAllDataEndpoint:
"""Tests for /api/v1/monitoring/data endpoint."""
@pytest.mark.asyncio
async def test_get_all_data_success(self, quart_test_client):
"""GET /api/v1/monitoring/data returns all data."""
response = await quart_test_client.get(
'/api/v1/monitoring/data', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert 'overview' in data['data']
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringDetailsEndpoints:
"""Tests for detail endpoints."""
@pytest.mark.asyncio
async def test_get_session_analysis(self, quart_test_client):
"""GET /api/v1/monitoring/sessions/{id}/analysis."""
response = await quart_test_client.get(
'/api/v1/monitoring/sessions/sess-1/analysis', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_get_message_details(self, quart_test_client):
"""GET /api/v1/monitoring/messages/{id}/details."""
response = await quart_test_client.get(
'/api/v1/monitoring/messages/msg-1/details', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringFeedbackEndpoints:
"""Tests for feedback endpoints."""
@pytest.mark.asyncio
async def test_get_feedback_stats(self, quart_test_client):
"""GET /api/v1/monitoring/feedback/stats."""
response = await quart_test_client.get(
'/api/v1/monitoring/feedback/stats', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_get_feedback_list(self, quart_test_client):
"""GET /api/v1/monitoring/feedback."""
response = await quart_test_client.get(
'/api/v1/monitoring/feedback', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringExportEndpoint:
"""Tests for /api/v1/monitoring/export endpoint."""
@pytest.mark.asyncio
async def test_export_messages(self, quart_test_client):
"""GET export?type=messages returns CSV."""
response = await quart_test_client.get(
'/api/v1/monitoring/export?type=messages', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
assert 'text/csv' in response.content_type
@pytest.mark.asyncio
async def test_export_llm_calls(self, quart_test_client):
"""GET export?type=llm-calls returns CSV."""
response = await quart_test_client.get(
'/api/v1/monitoring/export?type=llm-calls', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_export_sessions(self, quart_test_client):
"""GET export?type=sessions returns CSV."""
response = await quart_test_client.get(
'/api/v1/monitoring/export?type=sessions', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_export_feedback(self, quart_test_client):
"""GET export?type=feedback returns CSV."""
response = await quart_test_client.get(
'/api/v1/monitoring/export?type=feedback', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
+280
View File
@@ -0,0 +1,280 @@
"""
API integration tests for pipeline endpoints.
Tests real HTTP API behavior using Quart test client with mocked services.
Extends test_smoke.py coverage for pipeline-related endpoints.
Run: uv run pytest tests/integration/api/test_pipelines.py -q
"""
from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from tests.factories import FakeApp
pytestmark = pytest.mark.integration
# ============== FIXTURE FOR SYS.MODULES ISOLATION ==============
@pytest.fixture(scope='module')
def mock_circular_import_chain():
"""Break circular import chain for API controller."""
from tests.utils.import_isolation import isolated_sys_modules, MockLifecycleControlScope
class FakeMinimalApplication:
pass
mock_app = MagicMock()
mock_app.Application = FakeMinimalApplication
mock_entities = MagicMock()
mock_entities.LifecycleControlScope = MockLifecycleControlScope
clear = [
'langbot.pkg.api.http.controller.group',
'langbot.pkg.api.http.controller.groups',
'langbot.pkg.api.http.controller.groups.pipelines',
'langbot.pkg.api.http.controller.groups.pipelines.pipelines',
'langbot.pkg.api.http.controller.groups.pipelines.embed',
'langbot.pkg.api.http.controller.groups.pipelines.websocket_chat',
'langbot.pkg.api.http.controller.main',
]
with isolated_sys_modules(
mocks={
'langbot.pkg.core.app': mock_app,
'langbot.pkg.core.entities': mock_entities,
},
clear=clear,
):
# Import groups after mocking to populate preregistered_groups
import langbot.pkg.api.http.controller.groups.pipelines.pipelines as _pipelines # noqa: E402, F401
yield
# ============== FAKE APPLICATION WITH PIPELINE SERVICES ==============
@pytest.fixture(scope='module')
def fake_pipeline_app():
"""Create FakeApp with pipeline-specific services (module scope for reuse)."""
app = FakeApp()
# Pipeline config
app.instance_config.data.update(
{
'api': {'port': 5300},
'system': {'allow_modify_login_info': True, 'limitation': {}},
}
)
# Auth services
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
app.apikey_service = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
# Pipeline service
app.pipeline_service = Mock()
app.pipeline_service.get_pipeline_metadata = AsyncMock(
return_value=[
{'name': 'trigger', 'stages': []},
{'name': 'ai', 'stages': []},
]
)
app.pipeline_service.get_pipelines = AsyncMock(
return_value=[
{
'uuid': 'test-pipeline-uuid',
'name': 'Test Pipeline',
'description': 'Test description',
'created_at': '2024-01-01T00:00:00',
'updated_at': '2024-01-01T00:00:00',
'is_default': False,
}
]
)
app.pipeline_service.get_pipeline = AsyncMock(
return_value={
'uuid': 'test-pipeline-uuid',
'name': 'Test Pipeline',
'config': {},
}
)
app.pipeline_service.create_pipeline = AsyncMock(return_value={'uuid': 'new-pipeline-uuid'})
app.pipeline_service.update_pipeline = AsyncMock(return_value={})
app.pipeline_service.delete_pipeline = AsyncMock()
app.pipeline_service.copy_pipeline = AsyncMock(return_value={'uuid': 'copied-pipeline-uuid'})
# Bot service
app.bot_service = Mock()
app.bot_service.get_bots = AsyncMock(return_value=[])
app.bot_service.create_bot = AsyncMock(return_value={'uuid': 'new-bot-uuid'})
# MCP service (for extensions endpoint)
app.mcp_service = Mock()
app.mcp_service.get_mcp_servers = AsyncMock(return_value=[])
# Skill service (for extensions endpoint)
app.skill_service = Mock()
app.skill_service.list_skills = AsyncMock(return_value=[])
# Plugin connector (for extensions endpoint)
app.plugin_connector.list_plugins = AsyncMock(return_value=[])
return app
@pytest.fixture(scope='module')
async def quart_test_client(fake_pipeline_app, http_controller_cls):
"""Create Quart test client (module scope to avoid route re-registration)."""
controller = http_controller_cls(fake_pipeline_app)
await controller.initialize()
client = controller.quart_app.test_client()
yield client
# ============== PIPELINE ENDPOINT TESTS ==============
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestPipelineMetadataEndpoint:
"""Tests for /api/v1/pipelines/_/metadata endpoint."""
@pytest.mark.asyncio
async def test_get_pipeline_metadata_success(self, quart_test_client):
"""GET /api/v1/pipelines/_/metadata returns metadata list."""
response = await quart_test_client.get(
'/api/v1/pipelines/_/metadata', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'data' in data
assert isinstance(data['data'], dict)
@pytest.mark.asyncio
async def test_get_pipeline_metadata_requires_auth(self, quart_test_client):
"""Pipeline metadata endpoint requires authentication."""
response = await quart_test_client.get('/api/v1/pipelines/_/metadata')
assert response.status_code == 401
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestPipelinesListEndpoint:
"""Tests for /api/v1/pipelines endpoint."""
@pytest.mark.asyncio
async def test_get_pipelines_success(self, quart_test_client):
"""GET /api/v1/pipelines returns pipeline list."""
response = await quart_test_client.get('/api/v1/pipelines', headers={'Authorization': 'Bearer test_token'})
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'data' in data
@pytest.mark.asyncio
async def test_get_pipelines_with_sort_param(self, quart_test_client):
"""GET pipelines with sort parameter."""
response = await quart_test_client.get(
'/api/v1/pipelines?sort_by=created_at&sort_order=DESC', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestPipelinesCRUDEndpoints:
"""Tests for pipeline CRUD operations."""
@pytest.mark.asyncio
async def test_get_single_pipeline_success(self, quart_test_client):
"""GET /api/v1/pipelines/{uuid} returns pipeline."""
response = await quart_test_client.get(
'/api/v1/pipelines/test-pipeline-uuid', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'data' in data
@pytest.mark.asyncio
async def test_create_pipeline_success(self, quart_test_client):
"""POST /api/v1/pipelines creates new pipeline."""
response = await quart_test_client.post(
'/api/v1/pipelines',
headers={'Authorization': 'Bearer test_token'},
json={'name': 'New Pipeline', 'config': {}},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'uuid' in data['data']
@pytest.mark.asyncio
async def test_update_pipeline_success(self, quart_test_client):
"""PUT /api/v1/pipelines/{uuid} updates pipeline."""
response = await quart_test_client.put(
'/api/v1/pipelines/test-pipeline-uuid',
headers={'Authorization': 'Bearer test_token'},
json={'name': 'Updated Pipeline'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
@pytest.mark.asyncio
async def test_delete_pipeline_success(self, quart_test_client):
"""DELETE /api/v1/pipelines/{uuid} deletes pipeline."""
response = await quart_test_client.delete(
'/api/v1/pipelines/test-pipeline-uuid', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
@pytest.mark.asyncio
async def test_copy_pipeline_success(self, quart_test_client):
"""POST /api/v1/pipelines/{uuid}/copy copies pipeline."""
response = await quart_test_client.post(
'/api/v1/pipelines/test-pipeline-uuid/copy', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'uuid' in data['data']
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestPipelineExtensionsEndpoint:
"""Tests for pipeline extensions."""
@pytest.mark.asyncio
async def test_get_extensions(self, quart_test_client):
"""GET /api/v1/pipelines/{uuid}/extensions."""
response = await quart_test_client.get(
'/api/v1/pipelines/test-pipeline-uuid/extensions', headers={'Authorization': 'Bearer test_token'}
)
# Should return 200 if pipeline found
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
+337
View File
@@ -0,0 +1,337 @@
"""
API integration tests for provider/model endpoints.
Tests real HTTP API behavior for provider and model management.
Run: uv run pytest tests/integration/api/test_providers.py -q
"""
from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from tests.factories import FakeApp
pytestmark = pytest.mark.integration
@pytest.fixture(scope='module')
def mock_circular_import_chain():
"""Break circular import chain for API controller."""
from tests.utils.import_isolation import isolated_sys_modules, MockLifecycleControlScope
class FakeMinimalApplication:
pass
mock_app = MagicMock()
mock_app.Application = FakeMinimalApplication
mock_entities = MagicMock()
mock_entities.LifecycleControlScope = MockLifecycleControlScope
clear = [
'langbot.pkg.api.http.controller.group',
'langbot.pkg.api.http.controller.groups',
'langbot.pkg.api.http.controller.groups.provider',
'langbot.pkg.api.http.controller.groups.provider.providers',
'langbot.pkg.api.http.controller.groups.provider.models',
'langbot.pkg.api.http.controller.main',
]
with isolated_sys_modules(
mocks={
'langbot.pkg.core.app': mock_app,
'langbot.pkg.core.entities': mock_entities,
},
clear=clear,
):
import langbot.pkg.api.http.controller.groups.provider.providers as _providers # noqa: E402, F401
import langbot.pkg.api.http.controller.groups.provider.models as _models # noqa: E402, F401
yield
@pytest.fixture(scope='module')
def fake_provider_app():
"""Create FakeApp with provider/model services (module scope for reuse)."""
app = FakeApp()
app.instance_config.data.update(
{
'api': {'port': 5300},
'system': {'allow_modify_login_info': True, 'limitation': {}},
}
)
# Auth services
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
app.apikey_service = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
# Provider service
app.provider_service = Mock()
app.provider_service.get_providers = AsyncMock(
return_value=[{'uuid': 'test-provider-uuid', 'name': 'OpenAI', 'requester': 'chatcmpl'}]
)
app.provider_service.get_provider = AsyncMock(
return_value={'uuid': 'test-provider-uuid', 'name': 'OpenAI', 'requester': 'chatcmpl'}
)
app.provider_service.create_provider = AsyncMock(return_value='new-provider-uuid')
app.provider_service.update_provider = AsyncMock(return_value={})
app.provider_service.delete_provider = AsyncMock()
app.provider_service.get_provider_model_counts = AsyncMock(
return_value={'llm_count': 2, 'embedding_count': 1, 'rerank_count': 0}
)
# LLM model service
app.llm_model_service = Mock()
app.llm_model_service.get_llm_models = AsyncMock(return_value=[{'uuid': 'test-model-uuid', 'name': 'gpt-4'}])
app.llm_model_service.get_llm_model = AsyncMock(return_value={'uuid': 'test-model-uuid', 'name': 'gpt-4'})
app.llm_model_service.create_llm_model = AsyncMock(return_value={'uuid': 'new-model-uuid'})
app.llm_model_service.update_llm_model = AsyncMock(return_value={})
app.llm_model_service.delete_llm_model = AsyncMock()
# Embedding model service
app.embedding_models_service = Mock()
app.embedding_models_service.get_embedding_models = AsyncMock(return_value=[])
app.embedding_models_service.create_embedding_model = AsyncMock(return_value={'uuid': 'new-embedding-uuid'})
# Rerank model service
app.rerank_models_service = Mock()
app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[])
app.rerank_models_service.create_rerank_model = AsyncMock(return_value={'uuid': 'new-rerank-uuid'})
# Model manager
app.model_mgr = Mock()
app.model_mgr.load_provider = AsyncMock()
app.model_mgr.unload_provider = AsyncMock()
return app
@pytest.fixture(scope='module')
async def quart_test_client(fake_provider_app, http_controller_cls):
"""Create Quart test client (module scope to avoid route re-registration)."""
controller = http_controller_cls(fake_provider_app)
await controller.initialize()
client = controller.quart_app.test_client()
yield client
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestProviderEndpoints:
"""Tests for /api/v1/provider endpoints."""
@pytest.mark.asyncio
async def test_get_providers_success(self, quart_test_client):
"""GET /api/v1/provider/providers returns provider list with complete structure."""
response = await quart_test_client.get(
'/api/v1/provider/providers', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'data' in data
# Verify response structure completeness
providers = data['data']['providers']
assert isinstance(providers, list)
assert len(providers) == 1
# Verify required fields in provider object
provider = providers[0]
assert 'uuid' in provider
assert 'name' in provider
assert 'requester' in provider
assert provider['uuid'] == 'test-provider-uuid'
assert provider['name'] == 'OpenAI'
@pytest.mark.asyncio
async def test_get_single_provider_success(self, quart_test_client):
"""GET /api/v1/provider/providers/{uuid} returns complete provider structure."""
response = await quart_test_client.get(
'/api/v1/provider/providers/test-provider-uuid', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
# Verify response structure
provider = data['data']['provider']
assert 'uuid' in provider
assert 'name' in provider
assert 'requester' in provider
assert provider['uuid'] == 'test-provider-uuid'
@pytest.mark.asyncio
async def test_create_provider_success(self, quart_test_client):
"""POST /api/v1/provider/providers creates new provider with uuid returned."""
response = await quart_test_client.post(
'/api/v1/provider/providers',
headers={'Authorization': 'Bearer test_token'},
json={'name': 'New Provider', 'requester': 'chatcmpl'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
# Verify uuid is present and matches expected
assert 'data' in data
assert 'uuid' in data['data']
assert data['data']['uuid'] == 'new-provider-uuid'
@pytest.mark.asyncio
async def test_update_provider_success(self, quart_test_client):
"""PUT /api/v1/provider/providers/{uuid} updates provider."""
response = await quart_test_client.put(
'/api/v1/provider/providers/test-provider-uuid',
headers={'Authorization': 'Bearer test_token'},
json={'name': 'Updated Provider'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
@pytest.mark.asyncio
async def test_delete_provider_success(self, quart_test_client):
"""DELETE /api/v1/provider/providers/{uuid} deletes provider."""
response = await quart_test_client.delete(
'/api/v1/provider/providers/test-provider-uuid', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_get_provider_includes_model_counts(self, quart_test_client):
"""GET provider response includes model counts."""
response = await quart_test_client.get(
'/api/v1/provider/providers/test-provider-uuid', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
# Model counts are embedded in provider response
provider_data = data['data']['provider']
assert 'llm_count' in provider_data
assert 'embedding_count' in provider_data
assert 'rerank_count' in provider_data
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestModelEndpoints:
"""Tests for /api/v1/provider/models endpoints."""
@pytest.mark.asyncio
async def test_get_llm_models_success(self, quart_test_client):
"""GET /api/v1/provider/models/llm returns model list."""
response = await quart_test_client.get(
'/api/v1/provider/models/llm', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'data' in data
@pytest.mark.asyncio
async def test_get_single_llm_model_success(self, quart_test_client):
"""GET /api/v1/provider/models/llm/{uuid} returns model."""
response = await quart_test_client.get(
'/api/v1/provider/models/llm/test-model-uuid', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
@pytest.mark.asyncio
async def test_create_llm_model_success(self, quart_test_client):
"""POST /api/v1/provider/models/llm creates new model."""
response = await quart_test_client.post(
'/api/v1/provider/models/llm',
headers={'Authorization': 'Bearer test_token'},
json={'name': 'New Model', 'provider_uuid': 'test-provider-uuid'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'uuid' in data['data']
@pytest.mark.asyncio
async def test_delete_llm_model_success(self, quart_test_client):
"""DELETE /api/v1/provider/models/llm/{uuid} deletes model."""
response = await quart_test_client.delete(
'/api/v1/provider/models/llm/test-model-uuid', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestEmbeddingModelEndpoints:
"""Tests for /api/v1/provider/models/embedding endpoints."""
@pytest.mark.asyncio
async def test_get_embedding_models_success(self, quart_test_client):
"""GET /api/v1/provider/models/embedding returns model list."""
response = await quart_test_client.get(
'/api/v1/provider/models/embedding', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'models' in data['data']
@pytest.mark.asyncio
async def test_create_embedding_model_success(self, quart_test_client):
"""POST /api/v1/provider/models/embedding creates new model."""
response = await quart_test_client.post(
'/api/v1/provider/models/embedding',
headers={'Authorization': 'Bearer test_token'},
json={'name': 'New Embedding Model', 'provider_uuid': 'test-provider-uuid'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'uuid' in data['data']
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestRerankModelEndpoints:
"""Tests for /api/v1/provider/models/rerank endpoints."""
@pytest.mark.asyncio
async def test_get_rerank_models_success(self, quart_test_client):
"""GET /api/v1/provider/models/rerank returns model list."""
response = await quart_test_client.get(
'/api/v1/provider/models/rerank', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'models' in data['data']
@pytest.mark.asyncio
async def test_create_rerank_model_success(self, quart_test_client):
"""POST /api/v1/provider/models/rerank creates new model."""
response = await quart_test_client.post(
'/api/v1/provider/models/rerank',
headers={'Authorization': 'Bearer test_token'},
json={'name': 'New Rerank Model', 'provider_uuid': 'test-provider-uuid'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'uuid' in data['data']
+347
View File
@@ -0,0 +1,347 @@
"""
API smoke integration tests.
Tests real HTTP API behavior using Quart test client.
Validates controller/service/routing wiring without real provider/platform.
Run: uv run pytest tests/integration/api/test_smoke.py -q
"""
from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from tests.factories import FakeApp
pytestmark = pytest.mark.integration
# ============== FIXTURE FOR SYS.MODULES ISOLATION ==============
@pytest.fixture(scope='module')
def mock_circular_import_chain():
"""
Break circular import chain for API controller using isolated_sys_modules.
Chain: http_controller → groups/plugins → core.app → pipeline entities
We need to mock core.app to prevent the circular chain when importing HTTPController.
But we must allow groups to be imported to populate preregistered_groups.
"""
from tests.utils.import_isolation import isolated_sys_modules, MockLifecycleControlScope
# Mock core.app with minimal Application that groups can reference
class FakeMinimalApplication:
pass
mock_app = MagicMock()
mock_app.Application = FakeMinimalApplication
# Mock core.entities with proper Enum
mock_entities = MagicMock()
mock_entities.LifecycleControlScope = MockLifecycleControlScope
# Modules to clear (force re-import after mocking)
clear = [
'langbot.pkg.api.http.controller.group',
'langbot.pkg.api.http.controller.groups',
'langbot.pkg.api.http.controller.groups.system',
'langbot.pkg.api.http.controller.groups.user',
'langbot.pkg.api.http.controller.main',
]
with isolated_sys_modules(
mocks={
'langbot.pkg.core.app': mock_app,
'langbot.pkg.core.entities': mock_entities,
},
clear=clear,
):
# Import groups after mocking core.app/core.entities
import langbot.pkg.api.http.controller.group as _group_module # noqa: E402, F401
import langbot.pkg.api.http.controller.groups.system as _system_group # noqa: E402, F401
import langbot.pkg.api.http.controller.groups.user as _user_group # noqa: E402, F401
yield
# ============== FAKE APPLICATION FOR API TESTS ==============
@pytest.fixture
def fake_api_app():
"""
Create minimal FakeApp for API smoke tests with all required services.
Uses tests.factories.FakeApp as base and adds API-specific services.
"""
app = FakeApp()
# API-specific config
app.instance_config.data.update(
{
'api': {'port': 5300},
'plugin': {'enable_marketplace': True},
'space': {'url': 'https://space.langbot.app'},
'system': {'allow_modify_login_info': True, 'limitation': {}},
}
)
# API-specific services
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=False)
app.user_service.authenticate = AsyncMock(return_value='fake_token')
app.user_service.create_user = AsyncMock()
app.user_service.verify_jwt_token = AsyncMock(side_effect=ValueError('Invalid token'))
app.user_service.get_user_by_email = AsyncMock(return_value=Mock())
app.user_service.generate_jwt_token = AsyncMock(return_value='fake_token')
app.apikey_service = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
app.maintenance_service = Mock()
app.maintenance_service.get_storage_analysis = AsyncMock(return_value={})
app.plugin_connector.is_enable_plugin = False
app.plugin_connector.ping_plugin_runtime = AsyncMock()
app.task_mgr.get_tasks_dict = Mock(return_value={'tasks': []})
app.task_mgr.get_task_by_id = Mock(return_value=None)
# Required by controller groups
app.model_mgr = Mock()
app.platform_mgr = Mock()
app.pipeline_pool = Mock()
app.pipeline_mgr = Mock()
return app
# ============== QUART TEST CLIENT FIXTURE ==============
@pytest.fixture
async def quart_test_client(fake_api_app, http_controller_cls):
"""
Create Quart test client with real HTTPController and route registration.
Requires mock_circular_import_chain fixture to run first (usefixtures).
"""
controller = http_controller_cls(fake_api_app)
await controller.initialize()
client = controller.quart_app.test_client()
yield client
# ============== API SMOKE TESTS ==============
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestHealthEndpoint:
"""Tests for /healthz endpoint - simplest smoke test."""
@pytest.mark.asyncio
async def test_healthz_returns_ok(self, quart_test_client):
"""
/healthz endpoint returns {'code': 0, 'msg': 'ok'}.
This tests:
- HTTPController instantiation
- Quart app creation
- Route registration
- Basic response handling
"""
response = await quart_test_client.get('/healthz')
assert response.status_code == 200
data = await response.get_json()
assert data == {'code': 0, 'msg': 'ok'}
@pytest.mark.asyncio
async def test_healthz_no_auth_required(self, quart_test_client):
"""
/healthz doesn't require authentication.
Tests that AuthType.NONE endpoints work without headers.
"""
response = await quart_test_client.get('/healthz')
assert response.status_code == 200
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestSystemEndpoint:
"""Tests for /api/v1/system endpoints."""
@pytest.mark.asyncio
async def test_system_info_no_auth(self, quart_test_client):
"""
/api/v1/system/info returns system information without auth.
AuthType.NONE endpoint.
"""
response = await quart_test_client.get('/api/v1/system/info')
assert response.status_code == 200
data = await response.get_json()
# Verify response structure
assert data['code'] == 0
assert data['msg'] == 'ok'
assert 'data' in data
# Verify expected fields
system_data = data['data']
assert 'version' in system_data
assert 'debug' in system_data
assert 'edition' in system_data
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestProtectedEndpoints:
"""Tests for authentication/authorization behavior."""
@pytest.mark.asyncio
async def test_protected_endpoint_rejects_no_token(self, quart_test_client):
"""
Protected endpoint (USER_TOKEN) returns 401 without auth.
Tests that AuthType.USER_TOKEN properly rejects unauthorized requests.
"""
# /api/v1/user/check-token requires USER_TOKEN
response = await quart_test_client.get('/api/v1/user/check-token')
assert response.status_code == 401
data = await response.get_json()
# Verify error response structure
assert data['code'] == -1
assert 'msg' in data
@pytest.mark.asyncio
async def test_protected_endpoint_with_invalid_token(self, quart_test_client):
"""
Protected endpoint returns 401 with invalid token.
"""
response = await quart_test_client.get(
'/api/v1/user/check-token', headers={'Authorization': 'Bearer invalid_token'}
)
assert response.status_code == 401
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestInvalidPayload:
"""Tests for error handling with invalid payloads."""
@pytest.mark.asyncio
async def test_missing_json_body(self, quart_test_client):
"""
POST endpoint without JSON body handles gracefully.
"""
# /api/v1/user/auth expects JSON with 'user' and 'password'
response = await quart_test_client.post('/api/v1/user/auth')
# Should return error (500, 400, or 401) with stable JSON structure
assert response.status_code in (400, 500, 401)
data = await response.get_json()
# Verify error response has expected structure
assert 'code' in data
assert 'msg' in data
@pytest.mark.asyncio
async def test_invalid_json_structure(self, quart_test_client):
"""
POST with wrong JSON structure returns stable error.
"""
response = await quart_test_client.post('/api/v1/user/auth', json={'wrong_field': 'value'})
# Should return error with stable JSON structure
assert response.status_code in (400, 500, 401)
data = await response.get_json()
assert 'code' in data
assert 'msg' in data
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestUserInitEndpoint:
"""Tests for /api/v1/user/init endpoint."""
@pytest.mark.asyncio
async def test_user_init_get_returns_not_initialized(self, quart_test_client):
"""
GET /api/v1/user/init returns initialized status.
Uses fake user_service.is_initialized() = False.
"""
response = await quart_test_client.get('/api/v1/user/init')
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['msg'] == 'ok'
assert data['data']['initialized'] is False
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestRealImports:
"""Tests that verify real production code is imported."""
def test_http_controller_real_import(self):
"""
Verify HTTPController is real production class, not mock.
"""
from langbot.pkg.api.http.controller.main import HTTPController
assert HTTPController.__name__ == 'HTTPController'
assert hasattr(HTTPController, 'initialize')
assert hasattr(HTTPController, 'register_routes')
def test_group_real_import(self):
"""
Verify RouterGroup and AuthType are real production classes.
"""
from langbot.pkg.api.http.controller.group import RouterGroup, AuthType, preregistered_groups
assert RouterGroup.__name__ == 'RouterGroup'
assert hasattr(AuthType, 'NONE')
assert hasattr(AuthType, 'USER_TOKEN')
assert isinstance(preregistered_groups, list)
def test_system_group_registered(self):
"""
Verify SystemRouterGroup is registered in preregistered_groups.
"""
from langbot.pkg.api.http.controller.group import preregistered_groups
# Find system group
system_group = None
for g in preregistered_groups:
if g.name == 'system':
system_group = g
break
assert system_group is not None
assert system_group.path == '/api/v1/system'
def test_user_group_registered(self):
"""
Verify UserRouterGroup is registered in preregistered_groups.
"""
from langbot.pkg.api.http.controller.group import preregistered_groups
# Find user group
user_group = None
for g in preregistered_groups:
if g.name == 'user':
user_group = g
break
assert user_group is not None
assert user_group.path == '/api/v1/user'
@@ -0,0 +1,5 @@
"""
Persistence integration tests package.
Tests for database migrations and storage behavior.
"""
@@ -0,0 +1,267 @@
"""
SQLite migration integration tests.
Tests real Alembic migration behavior using temporary SQLite databases.
Validates the migration workflow from .github/workflows/test-migrations.yml.
Run: uv run pytest tests/integration/persistence/test_migrations.py -q
"""
from __future__ import annotations
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.persistence.alembic_runner import (
run_alembic_upgrade,
run_alembic_stamp,
get_alembic_current,
_ALEMBIC_DIR,
)
from alembic.config import Config
from alembic.script import ScriptDirectory
def _get_script_head() -> str:
"""Resolve the current Alembic head revision from the script directory.
Avoids hardcoding a revision number in assertions so adding a new
migration doesn't require editing the migration tests.
"""
cfg = Config()
cfg.set_main_option('script_location', _ALEMBIC_DIR)
return ScriptDirectory.from_config(cfg).get_current_head()
pytestmark = pytest.mark.integration
@pytest.fixture
def sqlite_db_url(tmp_path):
"""Create SQLite URL with temporary database file."""
db_file = tmp_path / 'test_migrations.db'
return f'sqlite+aiosqlite:///{db_file}'
@pytest.fixture
async def sqlite_engine(sqlite_db_url):
"""Create async SQLite engine."""
engine = create_async_engine(sqlite_db_url)
yield engine
await engine.dispose()
class TestSQLiteMigrationBaseline:
"""Tests for baseline stamp workflow."""
@pytest.mark.asyncio
async def test_baseline_stamp_sets_revision(self, sqlite_engine):
"""
Stamp baseline on existing tables sets correct revision.
Workflow:
1. Create tables via Base.metadata.create_all
2. Stamp with '0001_baseline'
3. Verify current revision is '0001_baseline'
"""
# Create all tables (simulates existing DB created by ORM)
async with sqlite_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Stamp baseline
await run_alembic_stamp(sqlite_engine, '0001_baseline')
# Verify revision
rev = await get_alembic_current(sqlite_engine)
assert rev == '0001_baseline', f"Expected '0001_baseline', got {rev}"
@pytest.mark.asyncio
async def test_baseline_stamp_on_empty_db(self, sqlite_engine):
"""
Stamp on empty database (no tables) still sets revision.
This is an edge case - stamping without tables.
"""
# Don't create tables - stamp directly
await run_alembic_stamp(sqlite_engine, '0001_baseline')
rev = await get_alembic_current(sqlite_engine)
assert rev == '0001_baseline'
class TestSQLiteMigrationUpgrade:
"""Tests for upgrade to head workflow."""
@pytest.mark.asyncio
async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
"""
Upgrade from baseline to head applies all migrations.
Workflow:
1. Create tables
2. Stamp baseline
3. Upgrade to head
4. Verify current revision is head
"""
# Create tables
async with sqlite_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Stamp baseline
await run_alembic_stamp(sqlite_engine, '0001_baseline')
# Upgrade to head
await run_alembic_upgrade(sqlite_engine, 'head')
# Verify revision
rev = await get_alembic_current(sqlite_engine)
assert rev is not None, 'Expected a revision after upgrade'
# Head should be the latest migration. Resolve the actual head from the
# Alembic script directory instead of hardcoding a revision number, so
# adding a new migration doesn't require editing this assertion.
assert rev == _get_script_head(), f'Expected head {_get_script_head()}, got {rev}'
@pytest.mark.asyncio
async def test_upgrade_idempotent(self, sqlite_engine):
"""
Running upgrade to head multiple times is idempotent.
Workflow:
1. Upgrade to head
2. Get revision
3. Upgrade to head again
4. Verify same revision
"""
# Create tables
async with sqlite_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Stamp and upgrade
await run_alembic_stamp(sqlite_engine, '0001_baseline')
await run_alembic_upgrade(sqlite_engine, 'head')
rev1 = await get_alembic_current(sqlite_engine)
# Upgrade again - should be idempotent
await run_alembic_upgrade(sqlite_engine, 'head')
rev2 = await get_alembic_current(sqlite_engine)
assert rev2 == rev1, f'Expected {rev1}, got {rev2}'
class TestSQLiteMigrationFreshDatabase:
"""Tests for fresh database workflow."""
@pytest.mark.asyncio
async def test_fresh_db_upgrade_from_scratch(self, tmp_path):
"""
Fresh database (no tables) can be upgraded directly to head.
Workflow:
1. Create fresh engine with new DB file
2. Create tables
3. Upgrade to head
4. Verify revision
"""
# Use different DB file for fresh test
fresh_db_file = tmp_path / 'test_migrations_fresh.db'
fresh_url = f'sqlite+aiosqlite:///{fresh_db_file}'
fresh_engine = create_async_engine(fresh_url)
# Create tables on fresh DB
async with fresh_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Upgrade to head directly (no baseline stamp)
await run_alembic_upgrade(fresh_engine, 'head')
# Verify revision
rev = await get_alembic_current(fresh_engine)
assert rev is not None, 'Expected a revision on fresh DB'
await fresh_engine.dispose()
@pytest.mark.asyncio
async def test_fresh_db_without_create_all_behavior(self, tmp_path):
"""
Fresh database without create_all - test actual behavior.
This tests what happens when migrations run on truly empty DB.
The behavior is determined by Alembic and migration scripts.
EXPECTED: Either:
1. Migration succeeds (if scripts handle empty DB)
2. Migration fails with specific error (if scripts require tables)
IMPORTANT: This test verifies the ACTUAL behavior, not accepting
any arbitrary failure with try-except pass.
"""
fresh_db_file = tmp_path / 'test_empty_migrations.db'
fresh_url = f'sqlite+aiosqlite:///{fresh_db_file}'
fresh_engine = create_async_engine(fresh_url)
# Capture the actual behavior
actual_result = None
actual_error = None
try:
await run_alembic_upgrade(fresh_engine, 'head')
rev = await get_alembic_current(fresh_engine)
actual_result = rev
except Exception as e:
actual_error = e
await fresh_engine.dispose()
# Verify specific behavior - one of two outcomes is expected
if actual_result is not None:
# Migration succeeded - verify revision exists
assert actual_result is not None, 'Revision should exist after successful migration'
else:
# Migration failed - verify the error type is known
# Alembic typically raises specific errors for missing tables
assert actual_error is not None, 'Error should be captured if migration failed'
# Log the error type for documentation (don't silently pass)
error_type = type(actual_error).__name__
# Acceptable error types for empty DB scenarios
acceptable_errors = [
'OperationalError', # SQLite table not found
'ProgrammingError', # SQLAlchemy errors
'CommandError', # Alembic command errors
]
assert error_type in acceptable_errors, (
f'Unexpected error type: {error_type}. '
f'This may indicate a regression in migration behavior. '
f'Error: {actual_error}'
)
class TestSQLiteMigrationGetCurrent:
"""Tests for get_alembic_current behavior."""
@pytest.mark.asyncio
async def test_get_current_on_unstamped_db_returns_none(self, sqlite_engine):
"""
get_alembic_current returns None for unstamped database.
"""
# Create tables but don't stamp
async with sqlite_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# No stamp - should return None
rev = await get_alembic_current(sqlite_engine)
assert rev is None, f'Expected None for unstamped DB, got {rev}'
@pytest.mark.asyncio
async def test_get_current_after_stamp_returns_revision(self, sqlite_engine):
"""
get_alembic_current returns correct revision after stamp.
"""
async with sqlite_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await run_alembic_stamp(sqlite_engine, '0001_baseline')
rev = await get_alembic_current(sqlite_engine)
assert rev == '0001_baseline'
@@ -0,0 +1,225 @@
"""
PostgreSQL migration integration tests.
Tests real Alembic migration behavior using PostgreSQL database.
Marked as slow - requires external PostgreSQL service.
Run locally (requires PostgreSQL):
TEST_POSTGRES_URL=postgresql+asyncpg://user:pass@localhost:5432/test_db \
uv run pytest tests/integration/persistence/test_migrations_postgres.py -q
CI runs automatically with PostgreSQL service container.
"""
from __future__ import annotations
import os
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.persistence.alembic_runner import (
run_alembic_upgrade,
run_alembic_stamp,
get_alembic_current,
_ALEMBIC_DIR,
)
from alembic.config import Config
from alembic.script import ScriptDirectory
def _get_script_head() -> str:
"""Resolve the current Alembic head revision from the script directory.
Avoids hardcoding a revision number in assertions so adding a new
migration doesn't require editing the migration tests.
"""
cfg = Config()
cfg.set_main_option('script_location', _ALEMBIC_DIR)
return ScriptDirectory.from_config(cfg).get_current_head()
pytestmark = [pytest.mark.integration, pytest.mark.slow]
@pytest.fixture
def postgres_url():
"""Get PostgreSQL URL from environment."""
url = os.environ.get('TEST_POSTGRES_URL')
if not url:
pytest.skip('TEST_POSTGRES_URL not set')
return url
@pytest.fixture
async def postgres_engine(postgres_url):
"""Create async PostgreSQL engine."""
engine = create_async_engine(postgres_url, isolation_level='AUTOCOMMIT')
yield engine
await engine.dispose()
@pytest.fixture
async def clean_tables(postgres_engine):
"""Drop all tables before and after each test for isolation."""
# Drop all tables before test
async with postgres_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
yield
# Drop all tables after test
async with postgres_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest.fixture
async def clean_alembic_version(postgres_engine):
"""Drop alembic_version table before and after each test."""
async with postgres_engine.begin() as conn:
# Drop alembic_version table if exists
try:
await conn.execute(text('DROP TABLE IF EXISTS alembic_version'))
except Exception:
pass
yield
async with postgres_engine.begin() as conn:
try:
await conn.execute(text('DROP TABLE IF EXISTS alembic_version'))
except Exception:
pass
class TestPostgreSQLMigrationBaseline:
"""Tests for baseline stamp workflow on PostgreSQL."""
@pytest.mark.asyncio
async def test_postgres_baseline_stamp_sets_revision(self, postgres_engine, clean_tables, clean_alembic_version):
"""
Stamp baseline on existing tables sets correct revision.
Workflow:
1. Create tables via Base.metadata.create_all
2. Stamp with '0001_baseline'
3. Verify current revision is '0001_baseline'
"""
# Create all tables (simulates existing DB created by ORM)
async with postgres_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Stamp baseline
await run_alembic_stamp(postgres_engine, '0001_baseline')
# Verify revision
rev = await get_alembic_current(postgres_engine)
assert rev == '0001_baseline', f"Expected '0001_baseline', got {rev}"
@pytest.mark.asyncio
async def test_postgres_baseline_stamp_on_empty_db(self, postgres_engine, clean_tables, clean_alembic_version):
"""
Stamp on empty database (no tables) still sets revision.
This is an edge case - stamping without tables.
"""
# Don't create tables - stamp directly
await run_alembic_stamp(postgres_engine, '0001_baseline')
rev = await get_alembic_current(postgres_engine)
assert rev == '0001_baseline'
class TestPostgreSQLMigrationUpgrade:
"""Tests for upgrade to head workflow on PostgreSQL."""
@pytest.mark.asyncio
async def test_postgres_upgrade_from_baseline_to_head(self, postgres_engine, clean_tables, clean_alembic_version):
"""
Upgrade from baseline to head applies all migrations.
Workflow:
1. Create tables
2. Stamp baseline
3. Upgrade to head
4. Verify current revision is head
"""
# Create tables
async with postgres_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Stamp baseline
await run_alembic_stamp(postgres_engine, '0001_baseline')
# Upgrade to head
await run_alembic_upgrade(postgres_engine, 'head')
# Verify revision
rev = await get_alembic_current(postgres_engine)
assert rev is not None, 'Expected a revision after upgrade'
# Head should be the latest migration. Resolve the actual head from the
# Alembic script directory instead of hardcoding a revision number, so
# adding a new migration doesn't require editing this assertion.
assert rev == _get_script_head(), f'Expected head {_get_script_head()}, got {rev}'
@pytest.mark.asyncio
async def test_postgres_upgrade_idempotent(self, postgres_engine, clean_tables, clean_alembic_version):
"""
Running upgrade to head multiple times is idempotent.
Workflow:
1. Upgrade to head
2. Get revision
3. Upgrade to head again
4. Verify same revision
"""
# Create tables
async with postgres_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Stamp and upgrade
await run_alembic_stamp(postgres_engine, '0001_baseline')
await run_alembic_upgrade(postgres_engine, 'head')
rev1 = await get_alembic_current(postgres_engine)
# Upgrade again - should be idempotent
await run_alembic_upgrade(postgres_engine, 'head')
rev2 = await get_alembic_current(postgres_engine)
assert rev2 == rev1, f'Expected {rev1}, got {rev2}'
class TestPostgreSQLMigrationGetCurrent:
"""Tests for get_alembic_current behavior on PostgreSQL."""
@pytest.mark.asyncio
async def test_postgres_get_current_on_unstamped_db_returns_none(
self, postgres_engine, clean_tables, clean_alembic_version
):
"""
get_alembic_current returns None for unstamped database.
"""
# Create tables but don't stamp
async with postgres_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# No stamp - should return None
rev = await get_alembic_current(postgres_engine)
assert rev is None, f'Expected None for unstamped DB, got {rev}'
@pytest.mark.asyncio
async def test_postgres_get_current_after_stamp_returns_revision(
self, postgres_engine, clean_tables, clean_alembic_version
):
"""
get_alembic_current returns correct revision after stamp.
"""
async with postgres_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await run_alembic_stamp(postgres_engine, '0001_baseline')
rev = await get_alembic_current(postgres_engine)
assert rev == '0001_baseline'
+5
View File
@@ -0,0 +1,5 @@
"""
Pipeline integration tests package.
Tests for full pipeline flow using fake provider/runner.
"""
@@ -0,0 +1,892 @@
"""
Pipeline full-flow integration tests.
Tests real pipeline stages with fake runner/provider.
Validates message processing through PreProcessor, Processor, and SendResponseBackStage.
Uses RuntimePipeline directly (not PipelineManager) to avoid DB dependency.
Run: uv run pytest tests/integration/pipeline -q --tb=short
"""
from __future__ import annotations
import pytest
import asyncio
from unittest.mock import AsyncMock, Mock
import sys
from tests.factories import FakeApp, text_query, mock_platform_adapter
from tests.factories.provider import FakeProvider
from tests.factories.platform import FakePlatform
pytestmark = pytest.mark.integration
# ============== FIXTURE FOR SYS.MODULES ISOLATION ==============
@pytest.fixture(scope='module')
def mock_circular_import_chain():
"""
Break circular import chain for pipeline modules using isolated_sys_modules.
Chain: pipeline → core.app → provider.runner → http_controller → groups/plugins
We mock minimal modules to allow importing RuntimePipeline, StageInstContainer,
and stage classes without triggering full application initialization.
After mocking, we import the stage modules so decorators register them.
"""
from tests.utils.import_isolation import isolated_sys_modules, MockLifecycleControlScope
# Mock core.entities with LifecycleControlScope enum
mock_core_entities = Mock()
mock_core_entities.LifecycleControlScope = MockLifecycleControlScope
# Mock core.app - Application class is referenced but not instantiated
mock_core_app = Mock()
# Mock provider.runner with preregistered_runners list
mock_runner = Mock()
mock_runner.preregistered_runners = [] # Will be populated in tests
# Mock utils.importutil - prevents auto-import of runners
mock_importutil = Mock()
mock_importutil.import_modules_in_pkg = lambda pkg: None
mock_importutil.import_modules_in_pkgs = lambda pkgs: None
# Modules to clear (force re-import after mocking)
clear = [
'langbot.pkg.pipeline.stage',
'langbot.pkg.pipeline.entities',
'langbot.pkg.pipeline.pipelinemgr',
'langbot.pkg.pipeline.preproc.preproc',
'langbot.pkg.pipeline.process.process',
'langbot.pkg.pipeline.process.handler',
'langbot.pkg.pipeline.process.handlers.chat',
'langbot.pkg.pipeline.process.handlers.command',
'langbot.pkg.pipeline.respback.respback',
'langbot.pkg.provider.runner',
]
with isolated_sys_modules(
mocks={
'langbot.pkg.core.entities': mock_core_entities,
'langbot.pkg.core.app': mock_core_app,
'langbot.pkg.provider.runner': mock_runner,
'langbot.pkg.utils.importutil': mock_importutil,
'langbot.pkg.pipeline.controller': Mock(),
'langbot.pkg.pipeline.pipelinemgr': Mock(),
},
clear=clear,
):
# Import stage modules AFTER clearing so decorators register them
from importlib import import_module
# Import stage base first
import_module('langbot.pkg.pipeline.stage')
# Import entities
import_module('langbot.pkg.pipeline.entities')
# Import specific stages to register them
import_module('langbot.pkg.pipeline.preproc.preproc')
import_module('langbot.pkg.pipeline.process.process')
import_module('langbot.pkg.pipeline.respback.respback')
# Import pipelinemgr for RuntimePipeline
import_module('langbot.pkg.pipeline.pipelinemgr')
yield
# ============== FAKE RUNNER ==============
class FakeRunner:
"""Minimal fake runner class for pipeline integration tests.
Note: preregistered_runners expects a CLASS, not an instance.
The handler calls runner_cls(self.ap, query.pipeline_config) to instantiate.
"""
name = 'local-agent'
def __init__(self, app=None, config=None):
self.app = app
self.config = config or {}
self._provider = FakeProvider()
# Instance-level configuration set via class attribute
self._response_text = 'fake response'
self._raise_error = None
@classmethod
def returns(cls, text: str):
"""Create a runner class configured to return specific text."""
# We create a subclass with configured response
class ConfiguredRunner(cls):
name = cls.name
_response_text = text
_raise_error = None
def __init__(self, app=None, config=None):
super().__init__(app, config)
self._response_text = text
return ConfiguredRunner
@classmethod
def raises(cls, error: Exception):
"""Create a runner class configured to raise an error."""
class ConfiguredRunner(cls):
name = cls.name
_response_text = None
_raise_error = error
def __init__(self, app=None, config=None):
super().__init__(app, config)
self._raise_error = error
return ConfiguredRunner
async def run(self, query):
"""Run the fake provider and yield messages."""
from langbot_plugin.api.entities.builtin.provider.message import Message
# Use the configured response/error
if self._raise_error:
raise self._raise_error
# Yield a simple message
yield Message(role='assistant', content=self._response_text)
# ============== PIPELINE APP FIXTURE ==============
@pytest.fixture
def pipeline_app():
"""
Create FakeApp with all dependencies required by pipeline stages.
PreProcessor needs: sess_mgr, model_mgr, tool_mgr, plugin_connector
Processor needs: instance_config, plugin_connector
SendResponseBackStage needs: logger
ChatMessageHandler needs: telemetry, survey
"""
app = FakeApp()
# Session/conversation mocks for PreProcessor
mock_session = Mock()
mock_session.launcher_type = Mock()
mock_session.launcher_type.value = 'person'
mock_session.launcher_id = 12345
mock_session.sender_id = 12345
mock_session.use_prompt_name = 'default'
mock_session.using_conversation = None
# Create a simple class to mimic Prompt behavior
class MockPrompt:
def __init__(self, name, messages):
self.name = name
self.messages = messages
def copy(self):
return MockPrompt(self.name, list(self.messages))
# Create real lists for messages
prompt_messages_list = []
messages_list = []
mock_prompt = MockPrompt('default', prompt_messages_list)
mock_conversation = Mock()
mock_conversation.prompt = mock_prompt
mock_conversation.messages = messages_list
mock_conversation.uuid = 'test-conversation-uuid'
mock_conversation.update_time = None
mock_conversation.create_time = None
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
# Model mock for PreProcessor
mock_model = Mock()
mock_model.model_entity = Mock()
mock_model.model_entity.uuid = 'test-model-uuid'
mock_model.model_entity.name = 'test-model'
mock_model.model_entity.abilities = ['func_call', 'vision']
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
# Tool manager mock
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
# Telemetry mock (required by ChatMessageHandler)
app.telemetry = Mock()
app.telemetry.start_send_task = AsyncMock()
# Survey mock
app.survey = None
return app
@pytest.fixture
def fake_platform_adapter():
"""Create a fake platform adapter for outbound capture."""
platform = FakePlatform(stream_output_supported=False)
adapter = mock_platform_adapter(platform)
return adapter, platform
@pytest.fixture
def set_fake_runner():
"""Factory fixture to set a fake runner CLASS in preregistered_runners."""
def _set_runner(runner_cls):
# preregistered_runners expects a list of runner classes
sys.modules['langbot.pkg.provider.runner'].preregistered_runners = [runner_cls]
return _set_runner
# ============== PIPELINE CONFIGURATION ==============
def create_minimal_pipeline_config():
"""Create minimal pipeline configuration for tests."""
return {
'ai': {
'runner': {'runner': 'local-agent', 'expire-time': None},
'local-agent': {
'model': {'primary': 'test-model-uuid', 'fallbacks': []},
'prompt': 'default',
'knowledge-bases': [],
},
},
'output': {
'force-delay': {'min': 0.0, 'max': 0.0},
'misc': {
'at-sender': False,
'quote-origin': False,
'exception-handling': 'show-hint',
'failure-hint': 'Request failed.',
},
},
'trigger': {
'misc': {'combine-quote-message': False},
},
}
# ============== HELPER TO PROCESS COROUTINE/GENERATOR ==============
async def collect_processor_results(processor, query, stage_name):
"""
Helper to handle the coroutine -> async_generator pattern.
Processor.process() returns a coroutine that yields an async_generator.
This helper handles both cases like RuntimePipeline does.
"""
result = processor.process(query, stage_name)
# Handle coroutine (await it to get async_generator)
if asyncio.iscoroutine(result):
result = await result
# Now iterate over async_generator
results = []
async for item in result:
results.append(item)
return results
# ============== TESTS ==============
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestPipelineStageChainReal:
"""Tests for real pipeline stage chain."""
@pytest.mark.asyncio
async def test_import_pipeline_modules(self):
"""Verify we can import real pipeline modules."""
from langbot.pkg.pipeline import stage, entities
from langbot.pkg.pipeline import pipelinemgr
assert hasattr(stage, 'PipelineStage')
assert hasattr(stage, 'preregistered_stages')
assert hasattr(entities, 'ResultType')
assert hasattr(entities, 'StageProcessResult')
assert hasattr(pipelinemgr, 'RuntimePipeline')
assert hasattr(pipelinemgr, 'StageInstContainer')
@pytest.mark.asyncio
async def test_stage_preregistration(self):
"""Verify stages are preregistered after fixture imports them."""
from langbot.pkg.pipeline import stage
# Check that our target stages are registered
assert 'PreProcessor' in stage.preregistered_stages
assert 'MessageProcessor' in stage.preregistered_stages
assert 'SendResponseBackStage' in stage.preregistered_stages
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestPreProcessorStage:
"""Tests for PreProcessor stage alone."""
@pytest.mark.asyncio
async def test_preproc_continues_on_valid_query(self, pipeline_app, fake_platform_adapter):
"""PreProcessor should return CONTINUE for valid text query."""
from langbot.pkg.pipeline import entities
from langbot.pkg.pipeline.preproc import preproc
adapter, platform = fake_platform_adapter
# Create query with adapter
query = text_query('hello')
query.adapter = adapter
query.pipeline_config = create_minimal_pipeline_config()
# Mock plugin_connector for PromptPreProcessing event
mock_event_ctx = Mock()
mock_event_ctx.event = Mock()
mock_event_ctx.event.default_prompt = [] # Real list
mock_event_ctx.event.prompt = [] # Real list
pipeline_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
# Create PreProcessor stage
preproc_stage = preproc.PreProcessor(pipeline_app)
result = await preproc_stage.process(query, 'PreProcessor')
assert result.result_type == entities.ResultType.CONTINUE
assert result.new_query.session is not None
assert result.new_query.user_message is not None
@pytest.mark.asyncio
async def test_preproc_sets_user_message(self, pipeline_app, fake_platform_adapter):
"""PreProcessor should set user_message from message_chain."""
from langbot.pkg.pipeline import entities
from langbot.pkg.pipeline.preproc import preproc
adapter, platform = fake_platform_adapter
query = text_query('test message content')
query.adapter = adapter
query.pipeline_config = create_minimal_pipeline_config()
# Mock plugin_connector for PromptPreProcessing event
mock_event_ctx = Mock()
mock_event_ctx.event = Mock()
mock_event_ctx.event.default_prompt = []
mock_event_ctx.event.prompt = []
pipeline_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
preproc_stage = preproc.PreProcessor(pipeline_app)
result = await preproc_stage.process(query, 'PreProcessor')
assert result.result_type == entities.ResultType.CONTINUE
# Check user_message content
assert result.new_query.user_message is not None
assert result.new_query.user_message.role == 'user'
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestProcessorStage:
"""Tests for MessageProcessor stage."""
@pytest.mark.asyncio
async def test_processor_calls_chat_handler(self, pipeline_app, fake_platform_adapter, set_fake_runner):
"""Processor should route to ChatMessageHandler for non-command messages."""
adapter, platform = fake_platform_adapter
# Set fake runner that returns pong
fake_runner = FakeRunner().returns('LANGBOT_FAKE_PONG')
set_fake_runner(fake_runner)
# Create query
query = text_query('hello')
query.adapter = adapter
query.pipeline_config = create_minimal_pipeline_config()
query.resp_messages = []
# Mock plugin_connector to not prevent default
mock_event_ctx = Mock()
mock_event_ctx.is_prevented_default = Mock(return_value=False)
mock_event_ctx.event = Mock()
mock_event_ctx.event.user_message_alter = None
pipeline_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
# Create Processor stage
from langbot.pkg.pipeline.process import process
processor_stage = process.Processor(pipeline_app)
await processor_stage.initialize(query.pipeline_config)
# Collect results using helper
results = await collect_processor_results(processor_stage, query, 'MessageProcessor')
assert len(results) >= 1
# Check that resp_messages was populated
assert len(query.resp_messages) >= 1
@pytest.mark.asyncio
async def test_processor_prevent_default_without_reply_interrupts(self, pipeline_app, fake_platform_adapter):
"""Processor should INTERRUPT when plugin prevents default without reply."""
from langbot.pkg.pipeline import entities
adapter, platform = fake_platform_adapter
# Create query
query = text_query('hello')
query.adapter = adapter
query.pipeline_config = create_minimal_pipeline_config()
# Mock plugin_connector to prevent default without reply
mock_event_ctx = Mock()
mock_event_ctx.is_prevented_default = Mock(return_value=True)
mock_event_ctx.event = Mock()
mock_event_ctx.event.reply_message_chain = None
pipeline_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
# Create Processor stage
from langbot.pkg.pipeline.process import process
processor_stage = process.Processor(pipeline_app)
await processor_stage.initialize(query.pipeline_config)
results = await collect_processor_results(processor_stage, query, 'MessageProcessor')
assert len(results) == 1
assert results[0].result_type == entities.ResultType.INTERRUPT
@pytest.mark.asyncio
async def test_processor_prevent_default_with_reply_continues(self, pipeline_app, fake_platform_adapter):
"""Processor should CONTINUE when plugin prevents default with reply."""
from langbot.pkg.pipeline import entities
from tests.factories.message import text_chain
adapter, platform = fake_platform_adapter
# Create query
query = text_query('hello')
query.adapter = adapter
query.pipeline_config = create_minimal_pipeline_config()
query.resp_messages = []
# Create reply chain
reply_chain = text_chain('plugin response')
# Mock plugin_connector to prevent default with reply
mock_event_ctx = Mock()
mock_event_ctx.is_prevented_default = Mock(return_value=True)
mock_event_ctx.event = Mock()
mock_event_ctx.event.reply_message_chain = reply_chain
pipeline_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
# Create Processor stage
from langbot.pkg.pipeline.process import process
processor_stage = process.Processor(pipeline_app)
await processor_stage.initialize(query.pipeline_config)
results = await collect_processor_results(processor_stage, query, 'MessageProcessor')
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.usefixtures('mock_circular_import_chain')
class TestRunnerExceptionFlow:
"""Tests for runner exception handling."""
@pytest.mark.asyncio
async def test_runner_exception_yields_interrupt(self, pipeline_app, fake_platform_adapter, set_fake_runner):
"""Runner exception should yield INTERRUPT with error notices."""
from langbot.pkg.pipeline import entities
adapter, platform = fake_platform_adapter
# Set fake runner that raises exception
fake_runner = FakeRunner().raises(ValueError('API Error: rate limit exceeded'))
set_fake_runner(fake_runner)
# Create query with exception handling config
config = create_minimal_pipeline_config()
config['output']['misc']['exception-handling'] = 'show-hint'
config['output']['misc']['failure-hint'] = 'Request failed.'
query = text_query('hello')
query.adapter = adapter
query.pipeline_config = config
# Mock plugin_connector to not prevent default
mock_event_ctx = Mock()
mock_event_ctx.is_prevented_default = Mock(return_value=False)
mock_event_ctx.event = Mock()
mock_event_ctx.event.user_message_alter = None
pipeline_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
# Create Processor stage
from langbot.pkg.pipeline.process import process
processor_stage = process.Processor(pipeline_app)
await processor_stage.initialize(query.pipeline_config)
results = await collect_processor_results(processor_stage, query, 'MessageProcessor')
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_runner_exception_show_error_mode(self, pipeline_app, fake_platform_adapter, set_fake_runner):
"""show-error mode should show actual exception message."""
from langbot.pkg.pipeline import entities
adapter, platform = fake_platform_adapter
# Set fake runner that raises specific exception
fake_runner = FakeRunner().raises(RuntimeError('Custom runtime error'))
set_fake_runner(fake_runner)
# Create query with show-error mode
config = create_minimal_pipeline_config()
config['output']['misc']['exception-handling'] = 'show-error'
query = text_query('hello')
query.adapter = adapter
query.pipeline_config = config
# Mock plugin_connector to not prevent default
mock_event_ctx = Mock()
mock_event_ctx.is_prevented_default = Mock(return_value=False)
mock_event_ctx.event = Mock()
mock_event_ctx.event.user_message_alter = None
pipeline_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
# Create Processor stage
from langbot.pkg.pipeline.process import process
processor_stage = process.Processor(pipeline_app)
await processor_stage.initialize(query.pipeline_config)
results = await collect_processor_results(processor_stage, query, 'MessageProcessor')
assert len(results) == 1
assert results[0].result_type == entities.ResultType.INTERRUPT
assert 'Custom runtime error' in results[0].user_notice
@pytest.mark.asyncio
async def test_runner_exception_hide_mode(self, pipeline_app, fake_platform_adapter, set_fake_runner):
"""hide mode should not show user notice."""
from langbot.pkg.pipeline import entities
adapter, platform = fake_platform_adapter
# Set fake runner that raises exception
fake_runner = FakeRunner().raises(Exception('Hidden error'))
set_fake_runner(fake_runner)
# Create query with hide mode
config = create_minimal_pipeline_config()
config['output']['misc']['exception-handling'] = 'hide'
query = text_query('hello')
query.adapter = adapter
query.pipeline_config = config
# Mock plugin_connector to not prevent default
mock_event_ctx = Mock()
mock_event_ctx.is_prevented_default = Mock(return_value=False)
mock_event_ctx.event = Mock()
mock_event_ctx.event.user_message_alter = None
pipeline_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
# Create Processor stage
from langbot.pkg.pipeline.process import process
processor_stage = process.Processor(pipeline_app)
await processor_stage.initialize(query.pipeline_config)
results = await collect_processor_results(processor_stage, query, 'MessageProcessor')
assert len(results) == 1
assert results[0].result_type == entities.ResultType.INTERRUPT
assert results[0].user_notice is None
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestSendResponseBackStage:
"""Tests for SendResponseBackStage."""
@pytest.mark.asyncio
async def test_send_response_calls_adapter(self, pipeline_app, fake_platform_adapter):
"""SendResponseBackStage should call adapter.reply_message."""
from langbot.pkg.pipeline import entities
from langbot.pkg.pipeline.respback import respback
from tests.factories.message import text_chain
from langbot_plugin.api.entities.builtin.provider.message import Message
adapter, platform = fake_platform_adapter
# Create query with response message
query = text_query('hello')
query.adapter = adapter
query.pipeline_config = create_minimal_pipeline_config()
# Add response message
query.resp_messages = [Message(role='assistant', content='test response')]
query.resp_message_chain = [text_chain('test response')]
# Create SendResponseBackStage
respback_stage = respback.SendResponseBackStage(pipeline_app)
result = await respback_stage.process(query, 'SendResponseBackStage')
assert result.result_type == entities.ResultType.CONTINUE
# Check that adapter was called
outbound = platform.get_outbound_messages()
assert len(outbound) == 1
assert outbound[0]['type'] == 'reply'
@pytest.mark.asyncio
async def test_send_response_failure_notifies_plugin_diagnostic(self, pipeline_app):
"""Plugin-provided deferred replies should report delivery failures."""
from langbot.pkg.pipeline import plugin_diagnostics
from langbot.pkg.pipeline.respback import respback
from tests.factories.message import text_chain
from langbot_plugin.api.entities.builtin.provider.message import Message
query = text_query('hello')
query.adapter.reply_message.side_effect = RuntimeError('send failed')
query.pipeline_config = create_minimal_pipeline_config()
query.current_stage_name = 'SendResponseBackStage'
query.resp_messages = [Message(role='assistant', content='test response')]
query.resp_message_chain = [text_chain('test response')]
plugin_diagnostics.record_plugin_response_source(
query,
0,
[
{
'kind': 'reply_message_chain',
'plugin': {'author': 'tester', 'name': 'demo'},
}
],
[{'manifest': {'metadata': {'author': 'observer', 'name': 'not-reply-source'}}}],
'NormalMessageResponded',
)
pipeline_app.plugin_connector.notify_plugin_diagnostic = AsyncMock()
respback_stage = respback.SendResponseBackStage(pipeline_app)
with pytest.raises(RuntimeError, match='send failed'):
await respback_stage.process(query, 'SendResponseBackStage')
pipeline_app.plugin_connector.notify_plugin_diagnostic.assert_awaited_once()
payload = pipeline_app.plugin_connector.notify_plugin_diagnostic.await_args.args[0]
assert payload['code'] == 'response_delivery_failed'
assert payload['plugin'] == {'author': 'tester', 'name': 'demo'}
assert payload['query']['event_name'] == 'NormalMessageResponded'
assert payload['delivery']['error_type'] == 'RuntimeError'
assert 'attribution_warning' not in payload['details']
@pytest.mark.asyncio
async def test_send_response_failure_warns_for_old_runtime_attribution(self, pipeline_app):
"""Older plugin runtimes without response_sources should get approximate diagnostics."""
from langbot.pkg.pipeline import plugin_diagnostics
from langbot.pkg.pipeline.respback import respback
from tests.factories.message import text_chain
from langbot_plugin.api.entities.builtin.provider.message import Message
query = text_query('hello')
query.adapter.reply_message.side_effect = RuntimeError('send failed')
query.pipeline_config = create_minimal_pipeline_config()
query.resp_messages = [Message(role='assistant', content='test response')]
query.resp_message_chain = [text_chain('test response')]
plugin_diagnostics.record_plugin_response_source(
query,
0,
None,
[{'manifest': {'metadata': {'author': 'tester', 'name': 'demo'}}}],
'NormalMessageResponded',
)
pipeline_app.plugin_connector.notify_plugin_diagnostic = AsyncMock()
respback_stage = respback.SendResponseBackStage(pipeline_app)
with pytest.raises(RuntimeError, match='send failed'):
await respback_stage.process(query, 'SendResponseBackStage')
payload = pipeline_app.plugin_connector.notify_plugin_diagnostic.await_args.args[0]
assert payload['plugin'] == {'author': 'tester', 'name': 'demo'}
assert 'attribution_warning' in payload['details']
@pytest.mark.asyncio
async def test_send_response_failure_ignores_query_variable_spoofing(self, pipeline_app):
"""Plugin-controlled query variables must not mask delivery failures."""
from langbot.pkg.pipeline.respback import respback
from tests.factories.message import text_chain
from langbot_plugin.api.entities.builtin.provider.message import Message
query = text_query('hello')
query.adapter.reply_message.side_effect = RuntimeError('send failed')
query.pipeline_config = create_minimal_pipeline_config()
query.resp_messages = [Message(role='assistant', content='test response')]
query.resp_message_chain = [text_chain('test response')]
query.variables['_plugin_response_sources'] = {0: ['malformed']}
pipeline_app.plugin_connector.notify_plugin_diagnostic = AsyncMock()
respback_stage = respback.SendResponseBackStage(pipeline_app)
with pytest.raises(RuntimeError, match='send failed'):
await respback_stage.process(query, 'SendResponseBackStage')
pipeline_app.plugin_connector.notify_plugin_diagnostic.assert_not_called()
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestStageChainIntegration:
"""Tests for full stage chain (PreProcessor -> Processor -> SendResponseBackStage)."""
@pytest.mark.asyncio
async def test_full_chain_text_message_flow(self, pipeline_app, fake_platform_adapter, set_fake_runner):
"""
Full chain: text message -> PreProcessor -> Processor -> SendResponseBackStage.
Validates:
- PreProcessor sets up session, user_message
- Processor calls runner and populates resp_messages
- SendResponseBackStage calls adapter.reply_message
"""
from langbot.pkg.pipeline import entities
from langbot.pkg.pipeline.preproc import preproc
from langbot.pkg.pipeline.process import process
from langbot.pkg.pipeline.respback import respback
adapter, platform = fake_platform_adapter
# Set fake runner
fake_runner = FakeRunner().returns('LANGBOT_FAKE_PONG')
set_fake_runner(fake_runner)
# Create query
config = create_minimal_pipeline_config()
query = text_query('ping')
query.adapter = adapter
query.pipeline_config = config
query.resp_messages = []
query.resp_message_chain = []
# Mock plugin_connector for PreProcessor and Processor events
mock_event_ctx_preproc = Mock()
mock_event_ctx_preproc.event = Mock()
mock_event_ctx_preproc.event.default_prompt = []
mock_event_ctx_preproc.event.prompt = []
mock_event_ctx_processor = Mock()
mock_event_ctx_processor.is_prevented_default = Mock(return_value=False)
mock_event_ctx_processor.event = Mock()
mock_event_ctx_processor.event.user_message_alter = None
pipeline_app.plugin_connector.emit_event = AsyncMock()
pipeline_app.plugin_connector.emit_event.side_effect = [
mock_event_ctx_preproc, # PreProcessor PromptPreProcessing
mock_event_ctx_processor, # Processor NormalMessageReceived
]
# Create stages
preproc_stage = preproc.PreProcessor(pipeline_app)
processor_stage = process.Processor(pipeline_app)
await processor_stage.initialize(config)
respback_stage = respback.SendResponseBackStage(pipeline_app)
# Run PreProcessor
result1 = await preproc_stage.process(query, 'PreProcessor')
assert result1.result_type == entities.ResultType.CONTINUE
query = result1.new_query
# Run Processor
results = await collect_processor_results(processor_stage, query, 'MessageProcessor')
assert len(results) >= 1
# Build resp_message_chain from resp_messages
from tests.factories.message import text_chain
for resp_msg in query.resp_messages:
if resp_msg.content:
query.resp_message_chain.append(text_chain(resp_msg.content))
# Run SendResponseBackStage
result3 = await respback_stage.process(query, 'SendResponseBackStage')
assert result3.result_type == entities.ResultType.CONTINUE
# Verify adapter was called
outbound = platform.get_outbound_messages()
assert len(outbound) >= 1
@pytest.mark.asyncio
async def test_chain_stops_on_interrupt(self, pipeline_app, fake_platform_adapter):
"""
Chain should stop when a stage returns INTERRUPT.
PreProcessor returns CONTINUE, Processor returns INTERRUPT (prevent_default).
"""
from langbot.pkg.pipeline import entities
from langbot.pkg.pipeline.preproc import preproc
from langbot.pkg.pipeline.process import process
adapter, platform = fake_platform_adapter
# Create query
query = text_query('hello')
query.adapter = adapter
query.pipeline_config = create_minimal_pipeline_config()
# Mock plugin_connector - PreProcessor continues, Processor interrupts
mock_event_ctx_preproc = Mock()
mock_event_ctx_preproc.event = Mock()
mock_event_ctx_preproc.event.default_prompt = []
mock_event_ctx_preproc.event.prompt = []
mock_event_ctx_processor = Mock()
mock_event_ctx_processor.is_prevented_default = Mock(return_value=True)
mock_event_ctx_processor.event = Mock()
mock_event_ctx_processor.event.reply_message_chain = None
pipeline_app.plugin_connector.emit_event = AsyncMock()
pipeline_app.plugin_connector.emit_event.side_effect = [
mock_event_ctx_preproc, # PreProcessor PromptPreProcessing
mock_event_ctx_processor, # Processor NormalMessageReceived
]
# Create stages
preproc_stage = preproc.PreProcessor(pipeline_app)
processor_stage = process.Processor(pipeline_app)
await processor_stage.initialize(query.pipeline_config)
# Run PreProcessor
result1 = await preproc_stage.process(query, 'PreProcessor')
assert result1.result_type == entities.ResultType.CONTINUE
query = result1.new_query
# Run Processor - should INTERRUPT
results = await collect_processor_results(processor_stage, query, 'MessageProcessor')
assert len(results) == 1
assert results[0].result_type == entities.ResultType.INTERRUPT
# Chain stops here - no resp_messages
assert len(query.resp_messages) == 0
@@ -0,0 +1,344 @@
"""Integration tests for the Valkey Search VDB backend.
These are SLOW, real-server tests. They are gated on ``TEST_VALKEY_URL`` and
skipped when it is unset (same precedent as the PostgreSQL migration tests).
Run locally against valkey/valkey-bundle:9.1.0::
podman run -d --name valkey-test-langbot -p 6380:6379 valkey/valkey-bundle:9.1.0
TEST_VALKEY_URL=valkey://localhost:6380 \\
uv run pytest tests/integration/vector/test_valkey_search.py -m slow -q
The default upstream fast CI lane (``-m "not slow"``) skips these; the local
supervisor validator MUST run them.
"""
from __future__ import annotations
import asyncio
import os
import uuid
from types import SimpleNamespace
from urllib.parse import urlparse
import pytest
pytestmark = [pytest.mark.integration, pytest.mark.slow]
def _parse_valkey_url(url: str) -> tuple[str, int, int]:
"""Parse ``valkey://host:port/db`` into ``(host, port, db)``."""
parsed = urlparse(url)
host = parsed.hostname or 'localhost'
port = parsed.port or 6379
db = 0
if parsed.path and parsed.path.strip('/'):
try:
db = int(parsed.path.strip('/'))
except ValueError:
db = 0
return host, port, db
@pytest.fixture
def valkey_config():
url = os.environ.get('TEST_VALKEY_URL')
if not url:
pytest.skip('TEST_VALKEY_URL not set')
host, port, db = _parse_valkey_url(url)
return {
'host': host,
'port': port,
'db': db,
'password': '',
'username': '',
'tls': False,
'index_algorithm': 'HNSW',
'distance_metric': 'COSINE',
}
def _make_ap(valkey_config):
"""Build a minimal fake ``ap`` with the config + a no-op logger."""
logger = SimpleNamespace(
info=lambda *a, **k: None,
warning=lambda *a, **k: None,
error=lambda *a, **k: None,
debug=lambda *a, **k: None,
)
instance_config = SimpleNamespace(data={'vdb': {'valkey_search': valkey_config}})
return SimpleNamespace(instance_config=instance_config, logger=logger)
@pytest.fixture
async def backend(valkey_config):
"""Create a Valkey Search backend, skip if module/server unavailable."""
from langbot.pkg.vector.vdbs.valkey_search import (
ValkeySearchVectorDatabase,
VALKEY_SEARCH_AVAILABLE,
)
if not VALKEY_SEARCH_AVAILABLE:
pytest.skip('valkey-glide not installed')
from glide import ft
ap = _make_ap(valkey_config)
db = ValkeySearchVectorDatabase(ap)
client = await db._ensure_client()
# Module-presence gate: FT.LIST must be available (Search module loaded).
try:
await ft.list(client)
except Exception as exc: # noqa: BLE001
await client.close()
pytest.skip(f'Valkey Search module not available: {exc}')
collection = f'test_{uuid.uuid4().hex[:12]}'
yield db, collection
# Cleanup
try:
await db.delete_collection(collection)
except Exception:
pass
if db._client is not None:
await db._client.close()
async def _poll_until(coro_factory, predicate, timeout=5.0, interval=0.2):
"""Poll an async result until predicate is true (indexer is async)."""
deadline = asyncio.get_event_loop().time() + timeout
result = await coro_factory()
while not predicate(result) and asyncio.get_event_loop().time() < deadline:
await asyncio.sleep(interval)
result = await coro_factory()
return result
def _sample_docs():
ids = ['d1', 'd2', 'd3']
embeddings = [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.9, 0.1, 0.0, 0.0],
]
metadatas = [
{'file_id': 'fileA', 'topic': 'cats'},
{'file_id': 'fileB', 'topic': 'dogs'},
{'file_id': 'fileA', 'topic': 'cats'},
]
documents = [
'the quick brown fox',
'lazy dogs sleeping',
'foxes and cats playing',
]
return ids, embeddings, metadatas, documents
@pytest.mark.asyncio
async def test_add_and_vector_search(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
result = await _poll_until(
lambda: db.search(collection, [1.0, 0.0, 0.0, 0.0], k=3, search_type='vector'),
lambda r: len(r['ids'][0]) >= 1,
)
assert len(result['ids'][0]) >= 1
# Closest to [1,0,0,0] should be d1.
assert result['ids'][0][0] == 'd1'
assert all(isinstance(d, float) for d in result['distances'][0])
@pytest.mark.asyncio
async def test_full_text_search(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
result = await _poll_until(
lambda: db.search(collection, [0.0, 0.0, 0.0, 0.0], k=5, search_type='full_text', query_text='dogs'),
lambda r: len(r['ids'][0]) >= 1,
)
assert 'd2' in result['ids'][0]
@pytest.mark.asyncio
async def test_hybrid_filter_then_knn(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
result = await _poll_until(
lambda: db.search(
collection,
[1.0, 0.0, 0.0, 0.0],
k=5,
search_type='hybrid',
query_text='cats',
filter={'file_id': 'fileA'},
),
lambda r: len(r['ids'][0]) >= 1,
)
# Only fileA docs (d1, d3) should be candidates.
assert set(result['ids'][0]).issubset({'d1', 'd3'})
@pytest.mark.asyncio
async def test_vector_weight_not_honored(backend):
"""Passing different vector_weight values must NOT change ranking."""
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
common = dict(
collection=collection, query_embedding=[1.0, 0.0, 0.0, 0.0], k=3, search_type='hybrid', query_text='cats'
)
await _poll_until(lambda: db.search(**common), lambda r: len(r['ids'][0]) >= 1)
r_low = await db.search(**common, vector_weight=0.1)
r_high = await db.search(**common, vector_weight=0.9)
assert r_low['ids'][0] == r_high['ids'][0]
@pytest.mark.asyncio
async def test_filter_operators(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
# Wait for indexing.
await _poll_until(
lambda: db.list_by_filter(collection, limit=10),
lambda r: r[1] >= 3,
)
# $eq
items, total = await db.list_by_filter(collection, filter={'file_id': 'fileA'})
assert total == 2
assert {it['id'] for it in items} == {'d1', 'd3'}
# $ne
items, total = await db.list_by_filter(collection, filter={'file_id': {'$ne': 'fileA'}})
assert {it['id'] for it in items} == {'d2'}
# $in
items, total = await db.list_by_filter(collection, filter={'file_id': {'$in': ['fileA', 'fileB']}})
assert total == 3
# $nin
items, total = await db.list_by_filter(collection, filter={'file_id': {'$nin': ['fileB']}})
assert {it['id'] for it in items} == {'d1', 'd3'}
@pytest.mark.asyncio
async def test_delete_by_file_id(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda r: r[1] >= 3)
await db.delete_by_file_id(collection, 'fileA')
items, total = await _poll_until(
lambda: db.list_by_filter(collection, limit=10),
lambda r: r[1] <= 1,
)
assert {it['id'] for it in items} == {'d2'}
@pytest.mark.asyncio
async def test_delete_by_filter_returns_count(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda r: r[1] >= 3)
deleted = await db.delete_by_filter(collection, filter={'file_id': 'fileA'})
assert deleted == 2
@pytest.mark.asyncio
async def test_list_by_filter_pagination(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda r: r[1] >= 3)
page1, total = await db.list_by_filter(collection, limit=2, offset=0)
assert total == 3
assert len(page1) == 2
page2, total = await db.list_by_filter(collection, limit=2, offset=2)
assert total == 3
assert len(page2) == 1
@pytest.mark.asyncio
async def test_delete_collection(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda r: r[1] >= 3)
await db.delete_collection(collection)
# After dropping, search on a missing index returns empty.
result = await db.search(collection, [1.0, 0.0, 0.0, 0.0], k=3, search_type='vector')
assert result['ids'][0] == []
@pytest.mark.asyncio
async def test_adversarial_filter_and_query_input(backend):
"""Crafted FT special chars in file_id / query_text must not break out.
Guarantees locked in here:
* A file_id full of injection-style chars (quotes, parens, ``|``, ``@``,
``:``, spaces, dashes) only ever matches its own row — the payload is
escaped to literal TAG content, never interpreted as extra clauses.
* A query_text full of FT operators does not raise and does not widen the
result set.
* A file_id containing FT-unsafe chars (``{`` / ``}`` / ``*``) is
percent-encoded, so it round-trips correctly: an exact match returns ONLY
its own row and never widens to an unrelated row, and the query does not
raise.
"""
db, collection = backend
# Injection-style file_id WITHOUT FT-unsafe chars (the realistic surface).
injection_fid = 'evil") @file_id (".id|x-y:z'
# file_id WITH FT-unsafe chars that previously could not be queried.
brace_fid = 'x} @file_id:{*'
ids = ['adv1', 'benign2', 'brace3']
embeddings = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]
metadatas = [{'file_id': injection_fid}, {'file_id': 'plainB'}, {'file_id': brace_fid}]
documents = ['payload row content', 'unrelated benign content', 'brace row content']
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda r: r[1] >= 3)
# Exact-match on the crafted file_id returns ONLY its own row.
items, total = await db.list_by_filter(collection, filter={'file_id': injection_fid})
assert total == 1
assert {it['id'] for it in items} == {'adv1'}
# A query_text packed with FT operators must not raise and must not match
# the benign row (escaped to literal terms, none of which it contains).
result = await db.search(
collection,
[0.0, 0.0, 0.0, 0.0],
k=5,
search_type='full_text',
query_text='@document:{*} | -()~ "evil"',
)
assert 'benign2' not in result['ids'][0]
# The brace/star-bearing file_id is encoded, so it round-trips: exact match
# returns ONLY its own row and never widens. No RequestError is raised.
b_items, b_total = await db.list_by_filter(collection, filter={'file_id': brace_fid})
assert b_total == 1
assert {it['id'] for it in b_items} == {'brace3'}
# And deletion by that file_id removes exactly its own row.
deleted = await db.delete_by_filter(collection, filter={'file_id': brace_fid})
assert deleted == 1
View File
@@ -0,0 +1,329 @@
"""Integration tests for LangBot Box.
These tests verify the end-to-end behavior of the Box sandbox execution
system. Tests decorated with ``requires_container`` need a real container
runtime (Podman or Docker) and are skipped otherwise.
CI only runs ``tests/unit_tests/``, so these tests never execute in the
CI pipeline. Run them locally with::
pytest tests/integration_tests/ -v
"""
from __future__ import annotations
import asyncio
import logging
import shutil
import socket
import subprocess
from types import SimpleNamespace
import pytest
from langbot.pkg.box.service import BoxService
from langbot_plugin.box.backend import BaseSandboxBackend
from langbot_plugin.box.client import ActionRPCBoxClient
from langbot_plugin.box.errors import BoxBackendUnavailableError
from langbot_plugin.box.models import BoxExecutionStatus, BoxNetworkMode, BoxSpec
from langbot_plugin.box.runtime import BoxRuntime
from langbot_plugin.box.server import BoxServerHandler
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
_logger = logging.getLogger('test.box.integration')
# Default image for integration tests — small and fast to pull.
_TEST_IMAGE = 'alpine:latest'
# ── Skip helpers ──────────────────────────────────────────────────────
def _has_container_runtime() -> bool:
for cmd in ('podman', 'docker'):
if shutil.which(cmd) is None:
continue
try:
result = subprocess.run(
[cmd, 'info'],
capture_output=True,
timeout=10,
)
if result.returncode == 0:
return True
except Exception:
continue
return False
def _can_open_test_socket() -> bool:
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
except OSError:
return False
sock.close()
return True
requires_container = pytest.mark.skipif(
not _has_container_runtime(),
reason='no container runtime (podman/docker) available',
)
requires_socket = pytest.mark.skipif(
not _can_open_test_socket(),
reason='local test environment does not permit opening TCP sockets',
)
# ── Helpers ──────────────────────────────────────────────────────────
class _QueueConnection:
"""In-process Connection backed by asyncio Queues — no real IO."""
def __init__(self, rx: asyncio.Queue[str], tx: asyncio.Queue[str]):
self._rx = rx
self._tx = tx
async def send(self, message: str) -> None:
await self._tx.put(message)
async def receive(self) -> str:
return await self._rx.get()
async def close(self) -> None:
pass
async def _make_rpc_pair(runtime: BoxRuntime):
"""Create an in-process (ActionRPCBoxClient, server_task, client_task) connected via queues."""
from langbot_plugin.runtime.io.handler import Handler
c2s: asyncio.Queue[str] = asyncio.Queue()
s2c: asyncio.Queue[str] = asyncio.Queue()
client_conn = _QueueConnection(rx=s2c, tx=c2s)
server_conn = _QueueConnection(rx=c2s, tx=s2c)
server_handler = BoxServerHandler(server_conn, runtime)
server_task = asyncio.create_task(server_handler.run())
client_handler = Handler.__new__(Handler)
Handler.__init__(client_handler, client_conn)
client_task = asyncio.create_task(client_handler.run())
client = ActionRPCBoxClient(logger=_logger)
client.set_handler(client_handler)
return client, server_task, client_task
# ── Fixtures ──────────────────────────────────────────────────────────
@pytest.fixture
async def box_client():
"""Yield an ActionRPCBoxClient backed by a real BoxRuntime via in-process RPC."""
runtime = BoxRuntime(logger=_logger)
await runtime.initialize()
client, server_task, client_task = await _make_rpc_pair(runtime)
yield client
server_task.cancel()
client_task.cancel()
await runtime.shutdown()
# ── 1. Simple command execution ───────────────────────────────────────
@requires_container
@requires_socket
@pytest.mark.asyncio
async def test_exec_simple_command(box_client: ActionRPCBoxClient):
"""Box starts a simple command and returns stdout."""
spec = BoxSpec(
cmd='echo hello-box',
session_id='int-simple',
workdir='/tmp',
image=_TEST_IMAGE,
)
result = await box_client.execute(spec)
assert result.status == BoxExecutionStatus.COMPLETED
assert result.exit_code == 0
assert 'hello-box' in result.stdout
# ── 2. Session file persistence ───────────────────────────────────────
@requires_container
@requires_socket
@pytest.mark.asyncio
async def test_session_persists_files(box_client: ActionRPCBoxClient):
"""Write a file in one exec, read it back in a second exec on the same session."""
sid = 'int-persist'
write_result = await box_client.execute(
BoxSpec(
cmd='echo "hello from file" > /tmp/testfile.txt',
session_id=sid,
workdir='/tmp',
image=_TEST_IMAGE,
)
)
assert write_result.exit_code == 0
read_result = await box_client.execute(
BoxSpec(
cmd='cat /tmp/testfile.txt',
session_id=sid,
workdir='/tmp',
image=_TEST_IMAGE,
)
)
assert read_result.exit_code == 0
assert 'hello from file' in read_result.stdout
# ── 3. Timeout handling ───────────────────────────────────────────────
@requires_container
@requires_socket
@pytest.mark.asyncio
async def test_timeout_kills_command(box_client: ActionRPCBoxClient):
"""A long-running command is killed after timeout_sec."""
session_id = 'int-timeout'
spec = BoxSpec(
cmd='sleep 120',
session_id=session_id,
workdir='/tmp',
timeout_sec=3,
image=_TEST_IMAGE,
)
result = await box_client.execute(spec)
assert result.status == BoxExecutionStatus.TIMED_OUT
assert result.exit_code is None
sessions = await box_client.get_sessions()
assert all(session['session_id'] != session_id for session in sessions)
# ── 4. Network isolation ─────────────────────────────────────────────
@requires_container
@requires_socket
@pytest.mark.asyncio
async def test_offline_cannot_reach_network(box_client: ActionRPCBoxClient):
"""With network=OFF the sandbox cannot reach the internet."""
spec = BoxSpec(
cmd='wget -q -O /dev/null --timeout=3 http://1.1.1.1 2>&1; exit $?',
session_id='int-offline',
workdir='/tmp',
network=BoxNetworkMode.OFF,
image=_TEST_IMAGE,
)
result = await box_client.execute(spec)
assert result.exit_code != 0
# ── 5. Backend unavailable ───────────────────────────────────────────
class _UnavailableBackend(BaseSandboxBackend):
"""A backend that always reports itself as unavailable."""
name = 'unavailable'
def __init__(self):
super().__init__(logging.getLogger('test'))
async def is_available(self) -> bool:
return False
async def start_session(self, spec):
raise NotImplementedError
async def exec(self, session, spec):
raise NotImplementedError
async def stop_session(self, session):
pass
@requires_socket
@pytest.mark.asyncio
async def test_backend_unavailable_returns_error():
"""When no backend is available the full RPC path returns BoxBackendUnavailableError."""
runtime = BoxRuntime(logger=_logger, backends=[_UnavailableBackend()])
await runtime.initialize()
client, server_task, client_task = await _make_rpc_pair(runtime)
try:
spec = BoxSpec(
cmd='echo hello',
session_id='int-no-backend',
workdir='/tmp',
)
with pytest.raises(BoxBackendUnavailableError):
await client.execute(spec)
finally:
server_task.cancel()
client_task.cancel()
await runtime.shutdown()
# ── 6. Full service-to-runtime path ──────────────────────────────────
@requires_container
@requires_socket
@pytest.mark.asyncio
async def test_full_service_to_remote_runtime(tmp_path):
"""BoxService -> ActionRPCBoxClient -> RPC -> BoxRuntime -> real backend."""
runtime = BoxRuntime(logger=_logger)
await runtime.initialize()
client, server_task, client_task = await _make_rpc_pair(runtime)
try:
host_dir = tmp_path / 'workspace'
host_dir.mkdir()
mock_ap = SimpleNamespace(
logger=_logger,
instance_config=SimpleNamespace(
data={
'box': {
'backend': 'local',
'runtime': {'endpoint': ''},
'local': {
'profile': 'default',
'allowed_mount_roots': [str(tmp_path)],
'default_workspace': str(host_dir),
},
'e2b': {'api_key': '', 'api_url': '', 'template': ''},
}
}
),
)
service = BoxService(mock_ap, client=client)
await service.initialize()
query = pipeline_query.Query.model_construct(query_id=42)
result = await service.execute_tool(
{'command': 'echo service-path'},
query,
)
assert result['ok'] is True
assert result['status'] == 'completed'
assert 'service-path' in result['stdout']
assert result['session_id'] == 'query_42'
finally:
server_task.cancel()
client_task.cancel()
await runtime.shutdown()
@@ -0,0 +1,368 @@
"""Integration tests for Box MCP-related features.
These tests verify managed process lifecycle, WebSocket stdio attach,
session cleanup, and the single-session query API using a real container
runtime.
CI only runs ``tests/unit_tests/``, so these tests never execute in the
CI pipeline. Run them locally with::
pytest tests/integration_tests/box/test_box_mcp_integration.py -v
"""
from __future__ import annotations
import asyncio
import logging
import shutil
import socket
import subprocess
import aiohttp
import pytest
from aiohttp.test_utils import TestServer
from langbot_plugin.box.client import ActionRPCBoxClient
from langbot_plugin.box.errors import BoxManagedProcessNotFoundError, BoxSessionNotFoundError
from langbot_plugin.box.models import BoxManagedProcessSpec, BoxManagedProcessStatus, BoxSpec
from langbot_plugin.box.runtime import BoxRuntime
from langbot_plugin.box.server import BoxServerHandler, create_ws_relay_app
_logger = logging.getLogger('test.box.mcp_integration')
_TEST_IMAGE = 'alpine:latest'
# ── Skip helpers ──────────────────────────────────────────────────────
def _has_container_runtime() -> bool:
for cmd in ('podman', 'docker'):
if shutil.which(cmd) is None:
continue
try:
result = subprocess.run([cmd, 'info'], capture_output=True, timeout=10)
if result.returncode == 0:
return True
except Exception:
continue
return False
def _can_open_test_socket() -> bool:
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
except OSError:
return False
sock.close()
return True
requires_container = pytest.mark.skipif(
not _has_container_runtime(),
reason='no container runtime (podman/docker) available',
)
requires_socket = pytest.mark.skipif(
not _can_open_test_socket(),
reason='local test environment does not permit opening TCP sockets',
)
# ── Helpers ──────────────────────────────────────────────────────────
class _QueueConnection:
"""In-process Connection backed by asyncio Queues — no real IO."""
def __init__(self, rx: asyncio.Queue[str], tx: asyncio.Queue[str]):
self._rx = rx
self._tx = tx
async def send(self, message: str) -> None:
await self._tx.put(message)
async def receive(self) -> str:
return await self._rx.get()
async def close(self) -> None:
pass
async def _make_rpc_pair(runtime: BoxRuntime):
"""Create an in-process RPC pair connected via queues."""
from langbot_plugin.runtime.io.handler import Handler
c2s: asyncio.Queue[str] = asyncio.Queue()
s2c: asyncio.Queue[str] = asyncio.Queue()
client_conn = _QueueConnection(rx=s2c, tx=c2s)
server_conn = _QueueConnection(rx=c2s, tx=s2c)
server_handler = BoxServerHandler(server_conn, runtime)
server_task = asyncio.create_task(server_handler.run())
client_handler = Handler.__new__(Handler)
Handler.__init__(client_handler, client_conn)
client_task = asyncio.create_task(client_handler.run())
client = ActionRPCBoxClient(logger=_logger)
client.set_handler(client_handler)
return client, server_task, client_task
# ── Fixtures ──────────────────────────────────────────────────────────
@pytest.fixture
async def box_server():
"""Yield a (ws_relay_url, ActionRPCBoxClient) backed by a real BoxRuntime."""
runtime = BoxRuntime(logger=_logger)
await runtime.initialize()
# Start ws relay for managed process attach
ws_app = create_ws_relay_app(runtime)
ws_server = TestServer(ws_app)
await ws_server.start_server()
client, server_task, client_task = await _make_rpc_pair(runtime)
ws_relay_url = str(ws_server.make_url(''))
yield ws_relay_url, client
server_task.cancel()
client_task.cancel()
await runtime.shutdown()
await ws_server.close()
# ── 1. Managed process lifecycle ─────────────────────────────────────
@requires_container
@requires_socket
@pytest.mark.asyncio
async def test_managed_process_start_and_query(box_server):
"""Start a managed process and query its status."""
ws_relay_url, client = box_server
# Create session
spec = BoxSpec(
cmd='',
session_id='mcp-int-lifecycle',
workdir='/tmp',
image=_TEST_IMAGE,
)
await client.create_session(spec)
# Start a managed process that stays alive
proc_spec = BoxManagedProcessSpec(
command='sh',
args=['-c', 'while true; do sleep 1; done'],
cwd='/tmp',
)
info = await client.start_managed_process('mcp-int-lifecycle', proc_spec)
assert info.status == BoxManagedProcessStatus.RUNNING
# Query it
info2 = await client.get_managed_process('mcp-int-lifecycle')
assert info2.status == BoxManagedProcessStatus.RUNNING
assert info2.command == 'sh'
# Stop only the managed process while keeping the session available
await client.stop_managed_process('mcp-int-lifecycle')
with pytest.raises(BoxManagedProcessNotFoundError):
await client.get_managed_process('mcp-int-lifecycle')
session_info = await client.get_session('mcp-int-lifecycle')
assert session_info['session_id'] == 'mcp-int-lifecycle'
# Cleanup
await client.delete_session('mcp-int-lifecycle')
# ── 2. WebSocket stdio attach ────────────────────────────────────────
@requires_container
@requires_socket
@pytest.mark.asyncio
async def test_ws_stdio_attach_echo(box_server):
"""Attach to a managed process via WebSocket and verify bidirectional IO."""
ws_relay_url, client = box_server
spec = BoxSpec(
cmd='',
session_id='mcp-int-ws',
workdir='/tmp',
image=_TEST_IMAGE,
)
await client.create_session(spec)
# Start a cat process (echoes stdin to stdout)
proc_spec = BoxManagedProcessSpec(
command='cat',
args=[],
cwd='/tmp',
)
await client.start_managed_process('mcp-int-ws', proc_spec)
# Connect via WebSocket (ws relay)
ws_url = client.get_managed_process_websocket_url('mcp-int-ws', ws_relay_url)
session = aiohttp.ClientSession()
try:
async with session.ws_connect(ws_url) as ws:
# Send a line
await ws.send_str('hello from test')
# Expect to receive it back (cat echoes)
msg = await asyncio.wait_for(ws.receive(), timeout=5)
assert msg.type == aiohttp.WSMsgType.TEXT
assert 'hello from test' in msg.data
finally:
await session.close()
await client.delete_session('mcp-int-ws')
# ── 3. Session cleanup removes container ─────────────────────────────
@requires_container
@requires_socket
@pytest.mark.asyncio
async def test_delete_session_cleans_up(box_server):
"""After deleting a session, it should no longer exist."""
ws_relay_url, client = box_server
spec = BoxSpec(
cmd='',
session_id='mcp-int-cleanup',
workdir='/tmp',
image=_TEST_IMAGE,
)
await client.create_session(spec)
# Start a process
proc_spec = BoxManagedProcessSpec(
command='sleep',
args=['3600'],
cwd='/tmp',
)
await client.start_managed_process('mcp-int-cleanup', proc_spec)
# Delete
await client.delete_session('mcp-int-cleanup')
# Session should be gone
with pytest.raises(BoxSessionNotFoundError):
await client.get_session('mcp-int-cleanup')
# ── 4. GET session details ────────────────────────────────────────
@requires_container
@requires_socket
@pytest.mark.asyncio
async def test_get_session_returns_details(box_server):
"""Get single session returns session details and managed process info."""
ws_relay_url, client = box_server
spec = BoxSpec(
cmd='',
session_id='mcp-int-get',
workdir='/tmp',
image=_TEST_IMAGE,
)
await client.create_session(spec)
# Query without managed process
info = await client.get_session('mcp-int-get')
assert info['session_id'] == 'mcp-int-get'
assert info['image'] == _TEST_IMAGE
assert 'managed_process' not in info
# Start a process and query again
proc_spec = BoxManagedProcessSpec(
command='sleep',
args=['3600'],
cwd='/tmp',
)
await client.start_managed_process('mcp-int-get', proc_spec)
info2 = await client.get_session('mcp-int-get')
assert info2['session_id'] == 'mcp-int-get'
assert 'managed_process' in info2
assert info2['managed_process']['status'] == BoxManagedProcessStatus.RUNNING.value
await client.delete_session('mcp-int-get')
# ── 5. Process exit detected ────────────────────────────────────────
@requires_container
@requires_socket
@pytest.mark.asyncio
async def test_process_exit_detected(box_server):
"""When a managed process exits, its status should reflect EXITED."""
ws_relay_url, client = box_server
spec = BoxSpec(
cmd='',
session_id='mcp-int-exit',
workdir='/tmp',
image=_TEST_IMAGE,
)
await client.create_session(spec)
# Start a process that exits immediately
proc_spec = BoxManagedProcessSpec(
command='sh',
args=['-c', 'echo done && exit 0'],
cwd='/tmp',
)
await client.start_managed_process('mcp-int-exit', proc_spec)
# Wait a bit for process to exit
await asyncio.sleep(2)
info = await client.get_managed_process('mcp-int-exit')
assert info.status == BoxManagedProcessStatus.EXITED
assert info.exit_code == 0
await client.delete_session('mcp-int-exit')
# ── 6. Instance ID orphan cleanup ───────────────────────────────────
@requires_container
@requires_socket
@pytest.mark.asyncio
async def test_orphan_cleanup_preserves_own_containers(box_server):
"""Orphan cleanup should not remove containers belonging to the current instance."""
ws_relay_url, client = box_server
# Create a session (container gets current instance ID label)
spec = BoxSpec(
cmd='',
session_id='mcp-int-orphan',
workdir='/tmp',
image=_TEST_IMAGE,
)
await client.create_session(spec)
# Verify session exists
sessions = await client.get_sessions()
assert any(s['session_id'] == 'mcp-int-orphan' for s in sessions)
# Trigger status check (which doesn't clean up own containers)
status = await client.get_status()
assert status['active_sessions'] >= 1
# Our session should still exist
sessions = await client.get_sessions()
assert any(s['session_id'] == 'mcp-int-orphan' for s in sessions)
await client.delete_session('mcp-int-orphan')
+132
View File
@@ -0,0 +1,132 @@
"""End-to-end test: boot the real MCPMount on a port and drive it with an MCP client.
Exercises the ASGI dispatcher (auth + /mcp routing), the FastMCP streamable-HTTP
transport, and a real tool call against the (mocked) service layer.
Run: uv run --no-sync python tests/manual/mcp_smoke.py
"""
from __future__ import annotations
import asyncio
import contextlib
from types import SimpleNamespace
from unittest.mock import AsyncMock
from hypercorn.asyncio import serve
from hypercorn.config import Config
from quart import Quart
from langbot.pkg.api.mcp.mount import MCPMount
PORT = 5399
GLOBAL_KEY = 'test-global-key-123'
def build_ap() -> SimpleNamespace:
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace(
data={'api': {'global_api_key': GLOBAL_KEY}, 'system': {'edition': 'community', 'instance_id': 'inst-1'}}
)
ap.ver_mgr = SimpleNamespace(get_current_version=lambda: '4.5.0-test')
ap.logger = SimpleNamespace(info=print, error=print, warning=print)
# API key verification: reuse real logic shape (global key match)
async def verify_api_key(key: str) -> bool:
return bool(key) and key == GLOBAL_KEY
ap.apikey_service = SimpleNamespace(verify_api_key=verify_api_key)
ap.bot_service = SimpleNamespace(
get_bots=AsyncMock(return_value=[{'uuid': 'bot-1', 'name': 'Demo Bot', 'adapter': 'telegram'}])
)
ap.pipeline_service = SimpleNamespace(get_pipelines=AsyncMock(return_value=[{'uuid': 'pl-1', 'name': 'default'}]))
ap.llm_model_service = SimpleNamespace(get_llm_models=AsyncMock(return_value=[]))
ap.embedding_models_service = SimpleNamespace(get_embedding_models=AsyncMock(return_value=[]))
ap.provider_service = SimpleNamespace(get_providers=AsyncMock(return_value=[]))
ap.knowledge_service = SimpleNamespace(get_knowledge_bases=AsyncMock(return_value=[]))
ap.mcp_service = SimpleNamespace(get_mcp_servers=AsyncMock(return_value=[]))
ap.skill_service = SimpleNamespace(list_skills=AsyncMock(return_value=[{'name': 'demo-skill'}]))
return ap
async def run_server(mount: MCPMount, shutdown: asyncio.Event) -> None:
quart_app = Quart(__name__)
@quart_app.route('/healthz')
async def healthz():
return {'code': 0, 'msg': 'ok'}
config = Config()
config.bind = [f'127.0.0.1:{PORT}']
config.accesslog = None
asgi = mount.wrap(quart_app)
await serve(asgi, config, shutdown_trigger=shutdown.wait)
async def main() -> int:
from mcp.client.session import ClientSession
from mcp.client.streamable_http import streamablehttp_client
ap = build_ap()
mount = MCPMount(ap)
await mount.start_session_manager()
shutdown = asyncio.Event()
server_task = asyncio.create_task(run_server(mount, shutdown))
await asyncio.sleep(1.0) # let the server bind
url = f'http://127.0.0.1:{PORT}/mcp'
failures = []
# 1. Unauthorized request is rejected.
import httpx
async with httpx.AsyncClient() as client:
r = await client.post(url, json={'jsonrpc': '2.0', 'id': 1, 'method': 'ping'})
if r.status_code != 401:
failures.append(f'expected 401 without key, got {r.status_code}')
else:
print('PASS: unauthorized request rejected (401)')
# 2. Authorized MCP session: list tools + call two.
headers = {'X-API-Key': GLOBAL_KEY}
async with streamablehttp_client(url, headers=headers) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
names = [t.name for t in tools.tools]
print(f'PASS: listed {len(names)} tools')
for required in ('list_bots', 'get_system_info', 'list_skills'):
if required not in names:
failures.append(f'missing tool {required}')
res = await session.call_tool('list_bots', {})
text = res.content[0].text if res.content else ''
if 'Demo Bot' not in text:
failures.append(f'list_bots did not return expected data: {text!r}')
else:
print('PASS: list_bots returned bot data')
res2 = await session.call_tool('get_system_info', {})
text2 = res2.content[0].text if res2.content else ''
if '4.5.0-test' not in text2:
failures.append(f'get_system_info wrong: {text2!r}')
else:
print('PASS: get_system_info returned version')
shutdown.set()
with contextlib.suppress(Exception):
await asyncio.wait_for(server_task, timeout=5)
await mount.stop_session_manager()
if failures:
print('\nFAILURES:')
for f in failures:
print(' -', f)
return 1
print('\nALL MCP SMOKE CHECKS PASSED')
return 0
if __name__ == '__main__':
raise SystemExit(asyncio.run(main()))
+6
View File
@@ -0,0 +1,6 @@
"""
Smoke tests package.
Smoke tests verify basic functionality works without testing edge cases.
Run with: uv run pytest tests/smoke/ -q
"""
+354
View File
@@ -0,0 +1,354 @@
"""
Minimal fake flow smoke tests for LangBot.
These tests verify basic component interactions using fake providers and platforms.
Not a full pipeline integration test - tests individual factory components.
For full pipeline tests, see tests/integration/ (planned).
"""
from __future__ import annotations
import pytest
from tests.factories import (
FakeApp,
FakeProvider,
FakePlatform,
text_query,
fake_provider_pong,
fake_model,
mock_platform_adapter,
)
class TestFakeMessageFlow:
"""Smoke tests for fake message flow through pipeline."""
@pytest.mark.asyncio
async def test_fake_app_creation(self):
"""Test FakeApp can be created with all dependencies."""
app = FakeApp()
assert app.logger is not None
assert app.sess_mgr is not None
assert app.model_mgr is not None
assert app.tool_mgr is not None
assert app.persistence_mgr is not None
assert app.query_pool is not None
assert app.instance_config is not None
# Verify default config
assert app.instance_config.data['command']['prefix'] == ['/', '!']
assert app.instance_config.data['command']['enable'] is True
@pytest.mark.asyncio
async def test_fake_provider_returns_text(self):
"""Test FakeProvider returns configured response."""
provider = FakeProvider(default_response='test response')
# Create mock model with provider
model = fake_model(provider=provider)
# Create a simple query
query = text_query('hello')
# Simulate invoke
result = await provider.invoke_llm(
query=query,
model=model,
messages=[],
funcs=[],
extra_args={},
)
assert result is not None
assert result.role == 'assistant'
assert result.content == 'test response'
@pytest.mark.asyncio
async def test_fake_provider_pong(self):
"""Test FakeProvider returns LANGBOT_FAKE_PONG marker."""
provider = fake_provider_pong()
model = fake_model(provider=provider)
query = text_query('ping')
result = await provider.invoke_llm(
query=query,
model=model,
messages=[],
funcs=[],
extra_args={},
)
assert result.content == FakeProvider.PONG_RESPONSE
@pytest.mark.asyncio
async def test_fake_provider_streaming(self):
"""Test FakeProvider streaming response."""
provider = FakeProvider().returns_streaming(['Hello', ' World'])
model = fake_model(provider=provider)
query = text_query('hello')
chunks = []
# invoke_llm_stream returns an async generator, don't await it
async for chunk in provider.invoke_llm_stream(
query=query,
model=model,
messages=[],
funcs=[],
extra_args={},
):
chunks.append(chunk)
assert len(chunks) == 2
assert chunks[0].content == 'Hello'
assert chunks[1].content == ' World'
assert chunks[1].is_final is True
@pytest.mark.asyncio
async def test_fake_provider_timeout(self):
"""Test FakeProvider simulates timeout error."""
provider = FakeProvider().timeout()
model = fake_model(provider=provider)
query = text_query('hello')
with pytest.raises(TimeoutError, match='Provider timeout'):
await provider.invoke_llm(
query=query,
model=model,
messages=[],
funcs=[],
extra_args={},
)
@pytest.mark.asyncio
async def test_fake_provider_rate_limit(self):
"""Test FakeProvider simulates rate limit error."""
provider = FakeProvider().rate_limit()
model = fake_model(provider=provider)
query = text_query('hello')
with pytest.raises(Exception, match='Rate limit exceeded'):
await provider.invoke_llm(
query=query,
model=model,
messages=[],
funcs=[],
extra_args={},
)
@pytest.mark.asyncio
async def test_fake_provider_captures_requests(self):
"""Test FakeProvider captures request arguments."""
provider = FakeProvider()
model = fake_model(name='gpt-4', provider=provider)
query = text_query('hello')
await provider.invoke_llm(
query=query,
model=model,
messages=[{'role': 'user', 'content': 'hello'}],
funcs=[{'name': 'test_func'}],
extra_args={'temperature': 0.7},
)
captured = provider.get_captured_requests()
assert len(captured) == 1
assert captured[0]['model'] == 'gpt-4'
assert captured[0]['messages'] == [{'role': 'user', 'content': 'hello'}]
assert captured[0]['funcs'] == [{'name': 'test_func'}]
assert captured[0]['extra_args'] == {'temperature': 0.7}
@pytest.mark.asyncio
async def test_fake_platform_capture_outbound(self):
"""Test FakePlatform captures outbound messages."""
platform = FakePlatform(bot_account_id='test-bot')
query = text_query('hello')
# Simulate sending reply
from tests.factories.message import text_chain
reply_chain = text_chain('response text')
event = query.message_event
await platform.reply_message(event, reply_chain, quote_origin=False)
# Verify captured
outbound = platform.get_outbound_messages()
assert len(outbound) == 1
assert outbound[0]['type'] == 'reply'
assert outbound[0]['message'] == reply_chain
@pytest.mark.asyncio
async def test_fake_platform_friend_message(self):
"""Test FakePlatform creates friend message events."""
platform = FakePlatform(bot_account_id='test-bot')
event = platform.create_friend_message(
text='hello bot',
sender_id=12345,
nickname='TestUser',
)
assert event.type == 'FriendMessage'
assert event.sender.id == 12345
assert event.sender.nickname == 'TestUser'
assert str(event.message_chain) == 'hello bot'
@pytest.mark.asyncio
async def test_fake_platform_group_message_with_mention(self):
"""Test FakePlatform creates group message with @mention."""
platform = FakePlatform(bot_account_id='test-bot')
event = platform.create_group_message(
text='hello everyone',
sender_id=12345,
group_id=99999,
mention_bot=True,
)
assert event.type == 'GroupMessage'
assert event.sender.id == 12345
assert event.group.id == 99999
# Check message chain has @mention
chain = event.message_chain
assert len(chain) >= 2 # At + Plain
@pytest.mark.asyncio
async def test_query_factories_basic(self):
"""Test basic query factory functions."""
# Text query
q1 = text_query('hello world')
assert q1.launcher_type.value == 'person'
assert str(q1.message_chain) == 'hello world'
# Group query
from tests.factories import group_text_query
q2 = group_text_query('hello group', group_id=88888)
assert q2.launcher_type.value == 'group'
assert q2.launcher_id == 88888
# Command query
from tests.factories import command_query
q3 = command_query('help', prefix='/')
assert str(q3.message_chain) == '/help'
# Mention query
from tests.factories import mention_query
q4 = mention_query('hi', target='test-bot', group_id=77777)
assert q4.launcher_type.value == 'group'
@pytest.mark.asyncio
async def test_fake_platform_send_failure(self):
"""Test FakePlatform simulates send failure."""
platform = FakePlatform().send_failure()
query = text_query('hello')
from tests.factories.message import text_chain
with pytest.raises(Exception, match='Platform send failure'):
await platform.reply_message(
query.message_event,
text_chain('response'),
)
@pytest.mark.asyncio
async def test_mock_platform_adapter(self):
"""Test mock_platform_adapter helper."""
platform = FakePlatform(bot_account_id='bot-123')
adapter = mock_platform_adapter(platform)
assert adapter.bot_account_id == 'bot-123'
assert adapter._fake_platform is platform
# Test reply_message is wired
from tests.factories.message import text_chain
query = text_query('test')
await adapter.reply_message(query.message_event, text_chain('response'))
# Verify platform captured it
assert len(platform.get_outbound_messages()) == 1
class TestMessageFlowIntegration:
"""Minimal fake flow integration tests.
These tests verify component interactions but do NOT run full LangBot pipeline.
For real pipeline tests, integration tests are needed (planned).
"""
@pytest.mark.asyncio
async def test_minimal_message_flow(self):
"""Minimal fake flow test: fake query -> fake provider -> fake platform.
This test verifies:
1. Fake text query is created
2. Fake provider returns LANGBOT_FAKE_PONG
3. Fake platform captures outbound response
4. No unexpected exception
Note: This does NOT run actual LangBot pipeline stages.
"""
# Setup
platform = FakePlatform(bot_account_id='test-bot')
provider = fake_provider_pong()
model = fake_model(provider=provider)
# Create inbound message
query = text_query('ping')
# Simulate provider processing
response = await provider.invoke_llm(
query=query,
model=model,
messages=[{'role': 'user', 'content': 'ping'}],
funcs=[],
extra_args={},
)
# Verify provider returned pong
assert response.content == FakeProvider.PONG_RESPONSE
# Simulate platform sending response
from tests.factories.message import text_chain
reply_chain = text_chain(response.content)
await platform.reply_message(query.message_event, reply_chain)
# Verify platform captured outbound
outbound = platform.get_outbound_messages()
assert len(outbound) == 1
assert outbound[0]['type'] == 'reply'
assert str(outbound[0]['message']) == FakeProvider.PONG_RESPONSE
@pytest.mark.asyncio
async def test_streaming_message_flow(self):
"""Smoke test: streaming message flow."""
platform = FakePlatform().supports_streaming()
provider = FakeProvider().returns_streaming(['Hello', ' there'])
model = fake_model(provider=provider)
query = text_query('hi')
chunks = []
async for chunk in provider.invoke_llm_stream(
query=query,
model=model,
messages=[],
funcs=[],
extra_args={},
):
chunks.append(chunk)
# Verify streaming worked
assert len(chunks) == 2
full_content = ''.join(c.content for c in chunks)
assert full_content == 'Hello there'
# Verify platform supports streaming
assert await platform.is_stream_output_supported() is True
+55
View File
@@ -0,0 +1,55 @@
"""
PoC test for CWE-94: Authenticated RCE via exec() on user-supplied Python code.
The /api/v1/system/debug/exec endpoint passes raw HTTP body to exec(),
allowing arbitrary code execution when debug_mode is True.
This test verifies that:
1. The exec() endpoint is removed from the codebase entirely.
2. No route matches /api/v1/system/debug/exec.
"""
import ast
import pathlib
# Resolve project root (one level up from tests/)
_PROJECT_ROOT = pathlib.Path(__file__).resolve().parent.parent
VULN_FILE = _PROJECT_ROOT / 'src' / 'langbot' / 'pkg' / 'api' / 'http' / 'controller' / 'groups' / 'system.py'
def test_no_exec_call_in_system_controller():
"""Verify there is no exec() call in system.py that takes user input."""
with open(VULN_FILE, 'r') as f:
source = f.read()
tree = ast.parse(source)
exec_calls = []
for node in ast.walk(tree):
if isinstance(node, ast.Call):
func = node.func
# Match bare exec() call
if isinstance(func, ast.Name) and func.id == 'exec':
exec_calls.append(node.lineno)
assert len(exec_calls) == 0, (
f'Found exec() call(s) at line(s) {exec_calls} in system.py. User-supplied code must never be passed to exec().'
)
def test_no_debug_exec_route():
"""Verify the /debug/exec route is not registered."""
with open(VULN_FILE, 'r') as f:
source = f.read()
assert 'debug/exec' not in source, (
'The /debug/exec route still exists in system.py. '
'This endpoint allows arbitrary code execution and must be removed.'
)
if __name__ == '__main__':
test_no_exec_call_in_system_controller()
test_no_debug_exec_route()
print('All tests passed!')
+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, valkey_search
- **排除原因**: 需要真实向量数据库实例运行
- **测试方式**: 需要 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 环境或集成测试,不属于纯单元测试范畴。
View File
+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,482 @@
"""
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."""
@staticmethod
def _make_ap(db_key=None, global_api_key=''):
"""Build a mock Application with persistence + instance_config."""
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = Mock()
mock_result.first = Mock(return_value=db_key)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.instance_config = SimpleNamespace(data={'api': {'global_api_key': global_api_key}})
return ap
async def test_verify_api_key_valid(self):
"""Returns True for valid API key."""
# Setup
key = Mock(spec=ApiKey)
ap = self._make_ap(db_key=key)
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 = self._make_ap(db_key=None)
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 = self._make_ap(db_key=None)
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 = self._make_ap(db_key=None)
service = ApiKeyService(ap)
# Execute
result = await service.verify_api_key('unknown_key')
# Verify
assert result is False
async def test_verify_global_api_key_match(self):
"""Returns True when key matches the config.yaml global API key (no DB lookup)."""
# Setup: no DB record, but a global key is configured
ap = self._make_ap(db_key=None, global_api_key='my-global-secret')
service = ApiKeyService(ap)
# Execute
result = await service.verify_api_key('my-global-secret')
# Verify: accepted purely on config match
assert result is True
# DB should not have been consulted for the global-key path
ap.persistence_mgr.execute_async.assert_not_called()
async def test_verify_global_api_key_no_prefix_required(self):
"""Global API key is accepted even without the lbk_ prefix."""
ap = self._make_ap(db_key=None, global_api_key='plainsecret123')
service = ApiKeyService(ap)
result = await service.verify_api_key('plainsecret123')
assert result is True
async def test_verify_global_api_key_mismatch_falls_back_to_db(self):
"""A non-matching key still falls through to the DB lookup."""
# Global key set, but request uses a different lbk_ key that IS in DB
key = Mock(spec=ApiKey)
ap = self._make_ap(db_key=key, global_api_key='my-global-secret')
service = ApiKeyService(ap)
result = await service.verify_api_key('lbk_db_key')
assert result is True
ap.persistence_mgr.execute_async.assert_called_once()
async def test_verify_empty_global_api_key_disabled(self):
"""An empty global_api_key must never authenticate an empty/blank request."""
ap = self._make_ap(db_key=None, global_api_key='')
service = ApiKeyService(ap)
# Empty request key is rejected, and a blank global key never matches
assert await service.verify_api_key('') is False
assert await service.verify_api_key(' ') is False
async def test_verify_api_key_missing_global_config_key(self):
"""Works even when api.global_api_key is absent (existing installs)."""
# instance_config without the global_api_key field at all
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)
ap.instance_config = SimpleNamespace(data={'api': {}})
service = ApiKeyService(ap)
result = await service.verify_api_key('lbk_some_key')
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,650 @@
"""
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,389 @@
"""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,820 @@
"""
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,715 @@
"""
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 TestMCPServiceResources:
"""Tests for MCP resource helpers."""
async def test_get_resource_templates_delegates_to_loader(self):
ap = SimpleNamespace()
ap.tool_mgr = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader.get_resource_templates = AsyncMock(
return_value=[{'uri_template': 'file:///{path}', 'name': 'files'}]
)
service = MCPService(ap)
result = await service.get_mcp_server_resource_templates('docs')
assert result == [{'uri_template': 'file:///{path}', 'name': 'files'}]
ap.tool_mgr.mcp_tool_loader.get_resource_templates.assert_awaited_once_with('docs')
async def test_read_resource_envelope_uses_ui_preview_source(self):
ap = SimpleNamespace()
ap.tool_mgr = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader.read_resource_envelope = AsyncMock(
return_value={
'server_name': 'docs',
'uri': 'file:///README.md',
'contents': [],
'source': 'ui_preview',
}
)
service = MCPService(ap)
result = await service.read_mcp_server_resource_envelope(
'docs',
'file:///README.md',
max_bytes=4096,
include_blob=True,
)
assert result['source'] == 'ui_preview'
ap.tool_mgr.mcp_tool_loader.read_resource_envelope.assert_awaited_once_with(
'docs',
'file:///README.md',
include_blob=True,
source='ui_preview',
max_bytes=4096,
)
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_duplicate_name_raises(self):
"""Rejects duplicate MCP server names."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'limitation': {'max_extensions': -1}}}
ap.tool_mgr = None
existing_server = _create_mock_mcp_server(name='Existing Server')
ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=existing_server))
ap.persistence_mgr.serialize_model = Mock(return_value={})
service = MCPService(ap)
# Execute & Verify
with pytest.raises(ValueError, match='MCP server already exists: Existing Server'):
await service.create_mcp_server({'name': 'Existing Server'})
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 result for duplicate-name 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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,871 @@
"""
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': [],
'mcp_resources': [],
'mcp_resource_agent_read_enabled': True,
}
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()
async def test_update_extensions_preserves_mcp_resource_agent_read_when_omitted(self):
"""Does not reset mcp_resource_agent_read_enabled when omitted by older clients."""
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': [],
'mcp_resources': [{'server_uuid': 'srv-1', 'uri': 'file:///README.md'}],
'mcp_resource_agent_read_enabled': False,
}
)
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'})
service = PipelineService(ap)
service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid'})
await service.update_pipeline_extensions('test-uuid', bound_plugins=[])
assert original_pipeline.extensions_preferences['mcp_resource_agent_read_enabled'] is False
assert original_pipeline.extensions_preferences['mcp_resources'] == [
{'server_uuid': 'srv-1', 'uri': 'file:///README.md'}
]
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,870 @@
"""
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,788 @@
"""
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,610 @@
"""
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,507 @@
"""
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,42 @@
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())
instance_config = SimpleNamespace(data={'api': {'global_api_key': ''}})
service = ApiKeyService(SimpleNamespace(persistence_mgr=persistence_mgr, instance_config=instance_config))
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))
instance_config = SimpleNamespace(data={'api': {'global_api_key': ''}})
service = ApiKeyService(SimpleNamespace(persistence_mgr=persistence_mgr, instance_config=instance_config))
result = await service.verify_api_key('lbk_valid_format')
assert result is expected
persistence_mgr.execute_async.assert_awaited_once()
@@ -0,0 +1,18 @@
from __future__ import annotations
import pytest
from langbot.pkg.api.http.controller.groups.box_visibility import should_hide_box_runtime_status
@pytest.mark.parametrize(
('edition', 'box_enabled', 'expected'),
[
('cloud', False, True),
('cloud', True, False),
('cloud', None, False),
('community', False, False),
],
)
def test_should_hide_box_runtime_status(edition, box_enabled, expected):
assert should_hide_box_runtime_status(edition, box_enabled) is expected
@@ -0,0 +1,76 @@
from __future__ import annotations
import sys
import types
from importlib import import_module
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import quart
core_app_module = types.ModuleType('langbot.pkg.core.app')
core_app_module.Application = object
sys.modules.setdefault('langbot.pkg.core.app', core_app_module)
pytestmark = pytest.mark.asyncio
async def _create_test_client(mcp_service: SimpleNamespace):
app = quart.Quart(__name__)
user_service = SimpleNamespace(
verify_jwt_token=AsyncMock(return_value='test@example.com'),
get_user_by_email=AsyncMock(return_value=SimpleNamespace(user='test@example.com')),
)
ap = SimpleNamespace(mcp_service=mcp_service, user_service=user_service)
MCPRouterGroup = import_module('langbot.pkg.api.http.controller.groups.resources.mcp').MCPRouterGroup
group = MCPRouterGroup(ap, app)
await group.initialize()
return app.test_client()
async def test_mcp_server_route_accepts_encoded_slash_name():
mcp_service = SimpleNamespace(
get_mcp_server_by_name=AsyncMock(
return_value={
'uuid': 'test-uuid',
'name': 'pab1it0/prometheus',
'enable': True,
'mode': 'stdio',
'extra_args': {},
}
)
)
client = await _create_test_client(mcp_service)
response = await client.get(
'/api/v1/mcp/servers/pab1it0%2Fprometheus',
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 200
mcp_service.get_mcp_server_by_name.assert_awaited_once_with('pab1it0/prometheus')
payload = await response.get_json()
assert payload['data']['server']['name'] == 'pab1it0/prometheus'
async def test_mcp_resource_route_accepts_encoded_slash_name():
mcp_service = SimpleNamespace(
get_mcp_server_by_name=AsyncMock(),
get_mcp_server_resources=AsyncMock(return_value=[]),
get_mcp_server_resource_templates=AsyncMock(return_value=[]),
get_runtime_info=AsyncMock(return_value={'resource_capabilities': {'subscribe': False}}),
)
client = await _create_test_client(mcp_service)
response = await client.get(
'/api/v1/mcp/servers/pab1it0%2Fprometheus/resources',
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 200
mcp_service.get_mcp_server_by_name.assert_not_awaited()
mcp_service.get_mcp_server_resources.assert_awaited_once_with('pab1it0/prometheus')
payload = await response.get_json()
assert payload['data']['resource_capabilities'] == {'subscribe': False}
+106
View File
@@ -0,0 +1,106 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from langbot_plugin.box.client import ActionRPCBoxClient
from langbot.pkg.box.connector import BoxRuntimeConnector
def make_app(logger: Mock, runtime_endpoint: str = ''):
return SimpleNamespace(
logger=logger,
instance_config=SimpleNamespace(
data={
'box': {
'backend': 'local',
'runtime': {'endpoint': runtime_endpoint},
'local': {
'profile': 'default',
'allowed_mount_roots': [],
'default_workspace': '',
},
'e2b': {'api_key': '', 'api_url': '', 'template': ''},
}
}
),
)
def test_box_runtime_connector_stdio_when_no_url(monkeypatch: pytest.MonkeyPatch):
"""Without runtime.endpoint, on a non-Docker Unix platform, use stdio."""
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
connector = BoxRuntimeConnector(make_app(Mock()))
assert connector._uses_websocket() is False
assert isinstance(connector.client, ActionRPCBoxClient)
def test_box_runtime_connector_ws_when_url_configured(monkeypatch: pytest.MonkeyPatch):
"""With an explicit runtime.endpoint, always use WebSocket."""
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
logger = Mock()
connector = BoxRuntimeConnector(make_app(logger, runtime_endpoint='http://box-runtime:5410'))
assert connector._uses_websocket() is True
assert isinstance(connector.client, ActionRPCBoxClient)
def test_box_runtime_connector_ws_in_docker(monkeypatch: pytest.MonkeyPatch):
"""Inside Docker (no explicit URL), use WebSocket to reach a sibling container."""
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'docker')
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
connector = BoxRuntimeConnector(make_app(Mock()))
assert connector._uses_websocket() is True
assert connector.ws_relay_base_url == 'http://langbot_box:5410'
def test_box_runtime_connector_ws_with_standalone_flag(monkeypatch: pytest.MonkeyPatch):
"""With --standalone-box flag, use WebSocket even on a local Unix platform."""
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', True)
connector = BoxRuntimeConnector(make_app(Mock()))
assert connector._uses_websocket() is True
def test_box_runtime_connector_ws_relay_url_default(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
connector = BoxRuntimeConnector(make_app(Mock()))
assert connector.ws_relay_base_url == 'http://127.0.0.1:5410'
def test_box_runtime_connector_ws_relay_url_explicit(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410'))
assert connector.ws_relay_base_url == 'http://box-runtime:5410'
def test_box_runtime_connector_dispose_terminates_subprocess(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
logger = Mock()
connector = BoxRuntimeConnector(make_app(logger))
subprocess = Mock()
subprocess.returncode = None
handler_task = Mock()
ctrl_task = Mock()
connector._subprocess = subprocess
connector._handler_task = handler_task
connector._ctrl_task = ctrl_task
connector.dispose()
subprocess.terminate.assert_called_once()
handler_task.cancel.assert_called_once()
ctrl_task.cancel.assert_called_once()
assert connector._handler_task is None
assert connector._ctrl_task is None
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
from __future__ import annotations
import os
import tempfile
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.box.workspace import (
BoxWorkspaceSession,
classify_python_workspace,
infer_workspace_host_path,
rewrite_mounted_path,
wrap_python_command_with_env,
)
def test_rewrite_mounted_path_translates_host_prefix():
result = rewrite_mounted_path('/tmp/demo/project/app.py', '/tmp/demo/project')
assert result == '/workspace/app.py'
def test_infer_workspace_host_path_unwraps_virtualenv_bin_dir():
with tempfile.TemporaryDirectory() as tmpdir:
project_root = os.path.join(tmpdir, 'project')
os.makedirs(os.path.join(project_root, '.venv', 'bin'))
python_bin = os.path.join(project_root, '.venv', 'bin', 'python')
script = os.path.join(project_root, 'server.py')
with open(python_bin, 'w', encoding='utf-8') as handle:
handle.write('')
with open(script, 'w', encoding='utf-8') as handle:
handle.write('print("ok")\n')
result = infer_workspace_host_path(python_bin, [script])
assert result == os.path.realpath(project_root)
def test_classify_python_workspace_detects_package_and_requirements():
with tempfile.TemporaryDirectory() as tmpdir:
assert classify_python_workspace(tmpdir) is None
with open(os.path.join(tmpdir, 'requirements.txt'), 'w', encoding='utf-8') as handle:
handle.write('requests\n')
assert classify_python_workspace(tmpdir) == 'requirements'
with open(os.path.join(tmpdir, 'pyproject.toml'), 'w', encoding='utf-8') as handle:
handle.write('[project]\nname = "demo"\n')
assert classify_python_workspace(tmpdir) == 'package'
def test_wrap_python_command_with_env_contains_bootstrap_and_command():
command = wrap_python_command_with_env('python script.py')
assert '_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"' in command
assert '"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"' in command
assert 'kill -0 "$_LB_LOCK_OWNER"' in command
assert 'export VIRTUAL_ENV="$_LB_VENV_DIR"' in command
assert command.rstrip().endswith('python script.py')
@pytest.mark.asyncio
async def test_workspace_session_execute_for_query_uses_session_payload():
box_service = SimpleNamespace(execute_spec_payload=AsyncMock(return_value={'ok': True}))
workspace = BoxWorkspaceSession(
box_service,
'skill-person_123-demo',
host_path='/tmp/project',
host_path_mode='rw',
env={'FOO': 'bar'},
)
query = SimpleNamespace(query_id='q1')
result = await workspace.execute_for_query(query, 'python run.py', workdir='/workspace', timeout_sec=30)
assert result == {'ok': True}
payload = box_service.execute_spec_payload.await_args.args[0]
assert payload == {
'session_id': 'skill-person_123-demo',
'workdir': '/workspace',
'env': {'FOO': 'bar'},
'persistent': False,
'host_path': '/tmp/project',
'host_path_mode': 'rw',
'cmd': 'python run.py',
'timeout_sec': 30,
}
@pytest.mark.asyncio
async def test_workspace_session_start_managed_process_rewrites_command_and_args():
box_service = SimpleNamespace(start_managed_process=AsyncMock(return_value={'status': 'running'}))
workspace = BoxWorkspaceSession(
box_service,
'mcp-u1',
host_path='/tmp/project',
host_path_mode='ro',
)
result = await workspace.start_managed_process(
'/tmp/project/.venv/bin/python',
['/tmp/project/server.py', '--config', '/tmp/project/config.json'],
env={'TOKEN': '1'},
)
assert result == {'status': 'running'}
session_id = box_service.start_managed_process.await_args.args[0]
payload = box_service.start_managed_process.await_args.args[1]
assert session_id == 'mcp-u1'
assert payload == {
'command': 'python',
'args': ['/workspace/server.py', '--config', '/workspace/config.json'],
'env': {'TOKEN': '1'},
'cwd': '/workspace',
'process_id': 'default',
}
def test_workspace_session_build_session_payload_keeps_generic_workspace_shape():
workspace = BoxWorkspaceSession(
Mock(),
'workspace-1',
host_path='/tmp/project',
host_path_mode='rw',
env={'FOO': 'bar'},
network='on',
read_only_rootfs=False,
image='python:3.11',
cpus=1.0,
memory_mb=512,
pids_limit=128,
)
assert workspace.build_session_payload() == {
'session_id': 'workspace-1',
'workdir': '/workspace',
'env': {'FOO': 'bar'},
'persistent': False,
'network': 'on',
'read_only_rootfs': False,
'host_path': '/tmp/project',
'host_path_mode': 'rw',
'image': 'python:3.11',
'cpus': 1.0,
'memory_mb': 512,
'pids_limit': 128,
}
+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
+303
View File
@@ -0,0 +1,303 @@
"""
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
+1
View File
@@ -0,0 +1 @@
# Config unit tests
@@ -0,0 +1,313 @@
"""
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
View File
@@ -0,0 +1 @@
"""Core module unit tests."""
@@ -0,0 +1,192 @@
"""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,139 @@
"""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=[])
+120
View File
@@ -0,0 +1,120 @@
"""Tests for the daily-grouped rotating log file handler.
Regression coverage for the bug where a long-running process names its log
file after the *start* day and keeps appending to it across midnight, so no
file ever appears for the current day. See
``langbot.pkg.core.bootutils.log.DailyGroupedRotatingFileHandler``.
"""
from __future__ import annotations
import logging
import os
import re
import langbot.pkg.core.bootutils.log as logmod
from langbot.pkg.core.bootutils.log import DailyGroupedRotatingFileHandler
# Mirror of the cleanup pattern in api/http/service/maintenance.py.
MAINTENANCE_LOG_FILE_PATTERN = re.compile(r'^langbot-(\d{4}-\d{2}-\d{2})\.log(?:\.\d+)?$')
def _listing(directory):
return sorted(os.listdir(directory))
def _make_logger(handler, name):
logger = logging.getLogger(name)
logger.setLevel(logging.INFO)
logger.handlers.clear()
logger.addHandler(handler)
logger.propagate = False
return logger
class TestDailyGroupedRotatingFileHandler:
def _patch_date(self, monkeypatch, box):
"""Make the handler read its current date from ``box['date']``."""
def fake_strftime(fmt, t=None):
if fmt == '%Y-%m-%d':
return box['date']
return '00:00:00'
monkeypatch.setattr(logmod.time, 'strftime', fake_strftime)
def test_initial_file_named_for_current_day(self, tmp_path, monkeypatch):
box = {'date': '2026-06-08'}
self._patch_date(monkeypatch, box)
handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=10_000, backup_count=3)
logger = _make_logger(handler, 'lb_logtest_initial')
logger.info('hello')
handler.close()
assert _listing(tmp_path) == ['langbot-2026-06-08.log']
def test_same_day_size_rotation_creates_numbered_backups(self, tmp_path, monkeypatch):
box = {'date': '2026-06-08'}
self._patch_date(monkeypatch, box)
handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=200, backup_count=3)
logger = _make_logger(handler, 'lb_logtest_size')
for i in range(40):
logger.info('padding line to exceed maxBytes %d', i)
handler.close()
files = _listing(tmp_path)
assert 'langbot-2026-06-08.log' in files
assert any(f.startswith('langbot-2026-06-08.log.') for f in files)
def test_rolls_to_new_file_when_day_changes(self, tmp_path, monkeypatch):
box = {'date': '2026-06-08'}
self._patch_date(monkeypatch, box)
handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=10_000, backup_count=3)
logger = _make_logger(handler, 'lb_logtest_midnight')
logger.info('day1 line')
# Simulate crossing midnight within the same running process.
box['date'] = '2026-06-09'
logger.info('day2 line after midnight')
handler.close()
files = _listing(tmp_path)
assert 'langbot-2026-06-08.log' in files
assert 'langbot-2026-06-09.log' in files
day2 = (tmp_path / 'langbot-2026-06-09.log').read_text(encoding='utf-8')
assert 'day2 line after midnight' in day2
assert 'day1 line' not in day2
def test_rollover_repeats_across_multiple_days(self, tmp_path, monkeypatch):
box = {'date': '2026-06-08'}
self._patch_date(monkeypatch, box)
handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=10_000, backup_count=3)
logger = _make_logger(handler, 'lb_logtest_multiday')
for day in ('2026-06-08', '2026-06-09', '2026-06-10'):
box['date'] = day
logger.info('line for %s', day)
handler.close()
files = _listing(tmp_path)
for day in ('2026-06-08', '2026-06-09', '2026-06-10'):
assert f'langbot-{day}.log' in files
def test_all_filenames_match_maintenance_cleanup_pattern(self, tmp_path, monkeypatch):
box = {'date': '2026-06-08'}
self._patch_date(monkeypatch, box)
handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=200, backup_count=3)
logger = _make_logger(handler, 'lb_logtest_pattern')
for i in range(40):
logger.info('padding line %d', i)
box['date'] = '2026-06-09'
logger.info('next day line')
handler.close()
for name in _listing(tmp_path):
assert MAINTENANCE_LOG_FILE_PATTERN.match(name), name
+284
View File
@@ -0,0 +1,284 @@
"""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'
+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,203 @@
"""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,156 @@
"""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,129 @@
"""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
+265
View File
@@ -0,0 +1,265 @@
"""
Shared test fixtures and configuration
This file provides infrastructure for all pipeline tests, including:
- Mock object factories
- Test fixtures
- Common test helper functions
"""
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock
# Preload pipelinemgr so the pipeline.stage module is fully initialised before
# any individual stage test (e.g. preproc, longtext) tries to import it. Without
# this, running a stage test in isolation triggers a circular-import error:
# stage.py → core.app → pipelinemgr → stage.stage_class (not yet bound).
import langbot.pkg.pipeline.pipelinemgr # noqa: F401
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.entities.builtin.provider.session as provider_session
from langbot.pkg.pipeline import entities as pipeline_entities
class MockApplication:
"""Mock Application object providing all basic dependencies needed by stages"""
def __init__(self):
self.logger = self._create_mock_logger()
self.sess_mgr = self._create_mock_session_manager()
self.model_mgr = self._create_mock_model_manager()
self.tool_mgr = self._create_mock_tool_manager()
self.plugin_connector = self._create_mock_plugin_connector()
self.persistence_mgr = self._create_mock_persistence_manager()
self.query_pool = self._create_mock_query_pool()
self.instance_config = self._create_mock_instance_config()
self.task_mgr = self._create_mock_task_manager()
# Skill manager is optional; PreProcessor only touches it for the
# local-agent runner. None keeps the skill-binding branch inert.
self.skill_mgr = None
def _create_mock_logger(self):
logger = Mock()
logger.debug = Mock()
logger.info = Mock()
logger.error = Mock()
logger.warning = Mock()
return logger
def _create_mock_session_manager(self):
sess_mgr = AsyncMock()
sess_mgr.get_session = AsyncMock()
sess_mgr.get_conversation = AsyncMock()
return sess_mgr
def _create_mock_model_manager(self):
model_mgr = AsyncMock()
model_mgr.get_model_by_uuid = AsyncMock()
return model_mgr
def _create_mock_tool_manager(self):
tool_mgr = AsyncMock()
tool_mgr.get_all_tools = AsyncMock(return_value=[])
return tool_mgr
def _create_mock_plugin_connector(self):
plugin_connector = AsyncMock()
plugin_connector.emit_event = AsyncMock()
return plugin_connector
def _create_mock_persistence_manager(self):
persistence_mgr = AsyncMock()
persistence_mgr.execute_async = AsyncMock()
return persistence_mgr
def _create_mock_query_pool(self):
query_pool = Mock()
query_pool.cached_queries = {}
query_pool.queries = []
query_pool.condition = AsyncMock()
return query_pool
def _create_mock_instance_config(self):
instance_config = Mock()
instance_config.data = {
'command': {'prefix': ['/', '!'], 'enable': True},
'concurrency': {'pipeline': 10},
}
return instance_config
def _create_mock_task_manager(self):
task_mgr = Mock()
task_mgr.create_task = Mock()
return task_mgr
@pytest.fixture
def mock_app():
"""Provides Mock Application instance"""
return MockApplication()
@pytest.fixture
def mock_session():
"""Provides Mock Session object"""
session = Mock()
session.launcher_type = provider_session.LauncherTypes.PERSON
session.launcher_id = 12345
session._semaphore = AsyncMock()
session._semaphore.locked = Mock(return_value=False)
session._semaphore.acquire = AsyncMock()
session._semaphore.release = AsyncMock()
return session
@pytest.fixture
def mock_conversation():
"""Provides Mock Conversation object"""
conversation = Mock()
conversation.uuid = 'test-conversation-uuid'
# Create mock prompt with copy method
mock_prompt = Mock()
mock_prompt.messages = []
mock_prompt.copy = Mock(return_value=Mock(messages=[]))
conversation.prompt = mock_prompt
# Create mock messages list with copy method
mock_messages = Mock()
mock_messages.copy = Mock(return_value=[])
conversation.messages = mock_messages
return conversation
@pytest.fixture
def mock_model():
"""Provides Mock Model object"""
model = Mock()
model.model_entity = Mock()
model.model_entity.uuid = 'test-model-uuid'
model.model_entity.abilities = ['func_call', 'vision']
return model
@pytest.fixture
def mock_adapter():
"""Provides Mock Adapter object"""
adapter = AsyncMock()
adapter.is_stream_output_supported = AsyncMock(return_value=False)
adapter.reply_message = AsyncMock()
adapter.reply_message_chunk = AsyncMock()
return adapter
@pytest.fixture
def sample_message_chain():
"""Provides sample message chain"""
return platform_message.MessageChain(
[
platform_message.Plain(text='Hello, this is a test message'),
]
)
@pytest.fixture
def sample_message_event(sample_message_chain):
"""Provides sample message event (FriendMessage)"""
sender = platform_entities.Friend(
id=12345,
nickname='TestUser',
remark=None,
)
return platform_events.FriendMessage(
type='FriendMessage',
sender=sender,
message_chain=sample_message_chain,
time=1609459200, # 2021-01-01 00:00:00
)
@pytest.fixture
def sample_query(sample_message_chain, sample_message_event, mock_adapter):
"""Provides sample Query object - using model_construct to bypass validation"""
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
# Use model_construct to bypass Pydantic validation for test purposes
query = pipeline_query.Query.model_construct(
query_id='test-query-id',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_chain=sample_message_chain,
message_event=sample_message_event,
adapter=mock_adapter,
pipeline_uuid='test-pipeline-uuid',
bot_uuid='test-bot-uuid',
pipeline_config={
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'},
},
'output': {'misc': {'at-sender': False, 'quote-origin': False}},
'trigger': {'misc': {'combine-quote-message': False}},
},
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,
)
return query
@pytest.fixture
def sample_pipeline_config():
"""Provides sample pipeline configuration"""
return {
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'},
},
'output': {'misc': {'at-sender': False, 'quote-origin': False}},
'trigger': {'misc': {'combine-quote-message': False}},
'ratelimit': {'enable': True, 'algo': 'fixwin', 'window': 60, 'limit': 10},
}
def create_stage_result(
result_type: pipeline_entities.ResultType,
query: pipeline_query.Query,
user_notice: str = '',
console_notice: str = '',
debug_notice: str = '',
error_notice: str = '',
) -> pipeline_entities.StageProcessResult:
"""Helper function to create stage process result"""
return pipeline_entities.StageProcessResult(
result_type=result_type,
new_query=query,
user_notice=user_notice,
console_notice=console_notice,
debug_notice=debug_notice,
error_notice=error_notice,
)
def assert_result_continue(result: pipeline_entities.StageProcessResult):
"""Assert result is CONTINUE type"""
assert result.result_type == pipeline_entities.ResultType.CONTINUE
def assert_result_interrupt(result: pipeline_entities.StageProcessResult):
"""Assert result is INTERRUPT type"""
assert result.result_type == pipeline_entities.ResultType.INTERRUPT
@@ -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
+138
View File
@@ -0,0 +1,138 @@
"""
BanSessionCheckStage unit tests
Tests the actual BanSessionCheckStage implementation from pkg.pipeline.bansess
"""
import pytest
from importlib import import_module
import langbot_plugin.api.entities.builtin.provider.session as provider_session
def get_modules():
"""Lazy import to ensure proper initialization order"""
# Import pipelinemgr first to trigger proper stage registration
bansess = import_module('langbot.pkg.pipeline.bansess.bansess')
entities = import_module('langbot.pkg.pipeline.entities')
return bansess, entities
@pytest.mark.asyncio
async def test_whitelist_allow(mock_app, sample_query):
"""Test whitelist allows matching session"""
bansess, entities = get_modules()
sample_query.launcher_type = provider_session.LauncherTypes.PERSON
sample_query.launcher_id = '12345'
sample_query.pipeline_config = {'trigger': {'access-control': {'mode': 'whitelist', 'whitelist': ['person_12345']}}}
stage = bansess.BanSessionCheckStage(mock_app)
await stage.initialize(sample_query.pipeline_config)
result = await stage.process(sample_query, 'BanSessionCheckStage')
assert result.result_type == entities.ResultType.CONTINUE
assert result.new_query == sample_query
@pytest.mark.asyncio
async def test_whitelist_deny(mock_app, sample_query):
"""Test whitelist denies non-matching session"""
bansess, entities = get_modules()
sample_query.launcher_type = provider_session.LauncherTypes.PERSON
sample_query.launcher_id = '99999'
sample_query.pipeline_config = {'trigger': {'access-control': {'mode': 'whitelist', 'whitelist': ['person_12345']}}}
stage = bansess.BanSessionCheckStage(mock_app)
await stage.initialize(sample_query.pipeline_config)
result = await stage.process(sample_query, 'BanSessionCheckStage')
assert result.result_type == entities.ResultType.INTERRUPT
@pytest.mark.asyncio
async def test_blacklist_allow(mock_app, sample_query):
"""Test blacklist allows non-matching session"""
bansess, entities = get_modules()
sample_query.launcher_type = provider_session.LauncherTypes.PERSON
sample_query.launcher_id = '12345'
sample_query.pipeline_config = {'trigger': {'access-control': {'mode': 'blacklist', 'blacklist': ['person_99999']}}}
stage = bansess.BanSessionCheckStage(mock_app)
await stage.initialize(sample_query.pipeline_config)
result = await stage.process(sample_query, 'BanSessionCheckStage')
assert result.result_type == entities.ResultType.CONTINUE
@pytest.mark.asyncio
async def test_blacklist_deny(mock_app, sample_query):
"""Test blacklist denies matching session"""
bansess, entities = get_modules()
sample_query.launcher_type = provider_session.LauncherTypes.PERSON
sample_query.launcher_id = '12345'
sample_query.pipeline_config = {'trigger': {'access-control': {'mode': 'blacklist', 'blacklist': ['person_12345']}}}
stage = bansess.BanSessionCheckStage(mock_app)
await stage.initialize(sample_query.pipeline_config)
result = await stage.process(sample_query, 'BanSessionCheckStage')
assert result.result_type == entities.ResultType.INTERRUPT
@pytest.mark.asyncio
async def test_wildcard_group(mock_app, sample_query):
"""Test group wildcard matching"""
bansess, entities = get_modules()
sample_query.launcher_type = provider_session.LauncherTypes.GROUP
sample_query.launcher_id = '12345'
sample_query.pipeline_config = {'trigger': {'access-control': {'mode': 'whitelist', 'whitelist': ['group_*']}}}
stage = bansess.BanSessionCheckStage(mock_app)
await stage.initialize(sample_query.pipeline_config)
result = await stage.process(sample_query, 'BanSessionCheckStage')
assert result.result_type == entities.ResultType.CONTINUE
@pytest.mark.asyncio
async def test_wildcard_person(mock_app, sample_query):
"""Test person wildcard matching"""
bansess, entities = get_modules()
sample_query.launcher_type = provider_session.LauncherTypes.PERSON
sample_query.launcher_id = '12345'
sample_query.pipeline_config = {'trigger': {'access-control': {'mode': 'whitelist', 'whitelist': ['person_*']}}}
stage = bansess.BanSessionCheckStage(mock_app)
await stage.initialize(sample_query.pipeline_config)
result = await stage.process(sample_query, 'BanSessionCheckStage')
assert result.result_type == entities.ResultType.CONTINUE
@pytest.mark.asyncio
async def test_user_id_wildcard(mock_app, sample_query):
"""Test user ID wildcard matching (*_id format)"""
bansess, entities = get_modules()
sample_query.launcher_type = provider_session.LauncherTypes.PERSON
sample_query.launcher_id = '12345'
sample_query.sender_id = '67890'
sample_query.pipeline_config = {'trigger': {'access-control': {'mode': 'whitelist', 'whitelist': ['*_67890']}}}
stage = bansess.BanSessionCheckStage(mock_app)
await stage.initialize(sample_query.pipeline_config)
result = await stage.process(sample_query, 'BanSessionCheckStage')
assert result.result_type == entities.ResultType.CONTINUE
@@ -0,0 +1,466 @@
"""
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
@@ -0,0 +1,78 @@
from __future__ import annotations
from unittest.mock import Mock
import pytest
import langbot_plugin.api.entities.builtin.provider.message as provider_message
# TODO: unskip once the handler ↔ app circular import is resolved
pytest.skip(
'circular import in handler ↔ app; will be unblocked once resolved',
allow_module_level=True,
)
from langbot.pkg.pipeline.process.handler import MessageHandler # noqa: E402
class _StubHandler(MessageHandler):
async def handle(self, query):
raise NotImplementedError
handler = _StubHandler(ap=Mock())
def test_chat_handler_formats_tool_call_request_log():
result = provider_message.Message(
role='assistant',
content='',
tool_calls=[
provider_message.ToolCall(
id='call-1',
type='function',
function=provider_message.FunctionCall(name='exec', arguments='{}'),
)
],
)
summary = handler.format_result_log(result)
assert summary == 'assistant: requested tools: exec'
def test_chat_handler_formats_tool_result_log():
result = provider_message.Message(
role='tool',
content='{"status":"completed","exit_code":0,"backend":"podman","stdout":"42\\n"}',
tool_call_id='call-1',
)
summary = handler.format_result_log(result)
# Tool results use generic cut_str truncation
assert summary is not None
assert summary.startswith('tool: {"status":"com')
assert summary.endswith('...')
def test_chat_handler_formats_tool_error_log():
result = provider_message.MessageChunk(
role='tool',
content='err: host_path must point to an existing directory on the host',
tool_call_id='call-1',
is_final=True,
)
summary = handler.format_result_log(result)
assert summary is not None
assert summary.startswith('tool error: err: host_path must')
assert summary.endswith('...')
def test_chat_handler_skips_empty_assistant_log():
result = provider_message.Message(role='assistant', content='')
summary = handler.format_result_log(result)
assert summary is None
@@ -0,0 +1,110 @@
from __future__ import annotations
from datetime import datetime, timedelta
from importlib import import_module
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
import yaml
def _preproc_module():
# Import pipelinemgr first so pipeline stages are registered without tripping
# the stage <-> core.app circular import during isolated test collection.
import_module('langbot.pkg.pipeline.pipelinemgr')
return import_module('langbot.pkg.pipeline.preproc.preproc')
def _entities_module():
return import_module('langbot.pkg.pipeline.entities')
def _conversation(created_at: datetime, updated_at: datetime | None = None):
prompt = Mock()
prompt.messages = []
prompt.copy = Mock(return_value=Mock(messages=[]))
return SimpleNamespace(
uuid='existing-conversation-uuid',
create_time=created_at,
update_time=updated_at,
prompt=prompt,
messages=[],
)
def _prompt_preprocessing_context(default_prompt=None, prompt=None):
ctx = Mock()
ctx.event.default_prompt = default_prompt or []
ctx.event.prompt = prompt or []
return ctx
async def _run_preprocessor(mock_app, sample_query, conversation):
session = SimpleNamespace(launcher_type=sample_query.launcher_type, launcher_id=sample_query.launcher_id)
mock_app.sess_mgr.get_session = AsyncMock(return_value=session)
mock_app.sess_mgr.get_conversation = AsyncMock(return_value=conversation)
mock_app.plugin_connector.emit_event = AsyncMock(return_value=_prompt_preprocessing_context())
sample_query.pipeline_config = {
'ai': {
'runner': {'runner': 'local-agent', 'expire-time': 60},
'local-agent': {'model': {'primary': '', 'fallbacks': []}, 'prompt': []},
},
'trigger': {'misc': {'combine-quote-message': False}},
'output': {'misc': {'exception-handling': 'show-hint'}},
}
return await _preproc_module().PreProcessor(mock_app).process(sample_query, 'PreProcessor')
@pytest.mark.asyncio
async def test_preprocessor_expires_conversation_from_last_update_time(mock_app, sample_query):
conversation = _conversation(
created_at=datetime.now() - timedelta(seconds=10),
updated_at=datetime.now() - timedelta(seconds=120),
)
result = await _run_preprocessor(mock_app, sample_query, conversation)
assert result.result_type == _entities_module().ResultType.CONTINUE
assert conversation.uuid is None
assert conversation.update_time > datetime.now() - timedelta(seconds=5)
assert result.new_query.variables['conversation_id'] is None
@pytest.mark.asyncio
async def test_preprocessor_keeps_conversation_when_last_update_is_not_expired(mock_app, sample_query):
conversation = _conversation(
created_at=datetime.now() - timedelta(seconds=120),
updated_at=datetime.now() - timedelta(seconds=30),
)
result = await _run_preprocessor(mock_app, sample_query, conversation)
assert result.result_type == _entities_module().ResultType.CONTINUE
assert conversation.uuid == 'existing-conversation-uuid'
assert conversation.update_time > datetime.now() - timedelta(seconds=5)
assert result.new_query.variables['conversation_id'] == 'existing-conversation-uuid'
def test_expire_time_metadata_lives_under_ai_runner_not_safety():
# 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())
ai_stage_names = [stage['name'] for stage in ai_meta['stages']]
assert 'session-limit' not in ai_stage_names
assert 'session-limit' not in [stage['name'] for stage in safety_meta['stages']]
runner_stage = next(stage for stage in ai_meta['stages'] if stage['name'] == 'runner')
expire_time = next(item for item in runner_stage['config'] if item['name'] == 'expire-time')
assert 'Conversation expire time' in expire_time['label']['en_US']
assert 'Session validity' not in expire_time['label']['en_US']
+512
View File
@@ -0,0 +1,512 @@
"""
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,403 @@
"""
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
@@ -0,0 +1,113 @@
"""Unit tests for config_coercion module"""
from __future__ import annotations
import pytest
from langbot.pkg.pipeline.config_coercion import _coerce_value, coerce_pipeline_config
class TestCoerceValue:
"""Tests for _coerce_value function"""
def test_none_passthrough(self):
assert _coerce_value(None, 'integer') is None
assert _coerce_value(None, 'boolean') is None
def test_string_to_integer(self):
assert _coerce_value('120', 'integer') == 120
assert _coerce_value('0', 'integer') == 0
assert _coerce_value('-5', 'integer') == -5
def test_integer_passthrough(self):
assert _coerce_value(42, 'integer') == 42
def test_string_to_float(self):
assert _coerce_value('3.14', 'number') == 3.14
assert _coerce_value('3.14', 'float') == 3.14
def test_int_to_float(self):
assert _coerce_value(3, 'number') == 3.0
assert isinstance(_coerce_value(3, 'number'), float)
def test_float_passthrough(self):
assert _coerce_value(3.14, 'float') == 3.14
def test_string_to_bool(self):
assert _coerce_value('true', 'boolean') is True
assert _coerce_value('True', 'boolean') is True
assert _coerce_value('false', 'boolean') is False
assert _coerce_value('False', 'boolean') is False
def test_bool_passthrough(self):
assert _coerce_value(True, 'boolean') is True
assert _coerce_value(False, 'boolean') is False
def test_invalid_bool_string_raises(self):
with pytest.raises(ValueError):
_coerce_value('notabool', 'boolean')
def test_unknown_type_passthrough(self):
assert _coerce_value('hello', 'string') == 'hello'
assert _coerce_value('hello', 'unknown') == 'hello'
def test_invalid_integer_raises(self):
with pytest.raises(ValueError):
_coerce_value('abc', 'integer')
class TestCoercePipelineConfig:
"""Tests for coerce_pipeline_config function"""
def _make_meta(self, section_name: str, stage_name: str, fields: list[dict]) -> dict:
return {
'name': section_name,
'stages': [{'name': stage_name, 'config': fields}],
}
def test_coerce_integer_in_config(self):
config = {'trigger': {'misc': {'timeout': '120'}}}
meta = self._make_meta('trigger', 'misc', [{'name': 'timeout', 'type': 'integer'}])
coerce_pipeline_config(config, meta)
assert config['trigger']['misc']['timeout'] == 120
def test_coerce_boolean_in_config(self):
config = {'output': {'misc': {'at-sender': 'true'}}}
meta = self._make_meta('output', 'misc', [{'name': 'at-sender', 'type': 'boolean'}])
coerce_pipeline_config(config, meta)
assert config['output']['misc']['at-sender'] is True
def test_missing_section_skipped(self):
config = {'ai': {}}
meta = self._make_meta('trigger', 'misc', [{'name': 'x', 'type': 'integer'}])
coerce_pipeline_config(config, meta) # should not raise
def test_missing_field_skipped(self):
config = {'trigger': {'misc': {}}}
meta = self._make_meta('trigger', 'misc', [{'name': 'nonexistent', 'type': 'integer'}])
coerce_pipeline_config(config, meta) # should not raise
def test_invalid_value_logs_warning(self, caplog):
config = {'trigger': {'misc': {'timeout': 'abc'}}}
meta = self._make_meta('trigger', 'misc', [{'name': 'timeout', 'type': 'integer'}])
import logging
with caplog.at_level(logging.WARNING):
coerce_pipeline_config(config, meta)
assert config['trigger']['misc']['timeout'] == 'abc' # unchanged
assert 'Failed to coerce' in caplog.text
def test_empty_metadata(self):
config = {'trigger': {'misc': {'timeout': '120'}}}
coerce_pipeline_config(config) # no metadata args, should not raise
def test_multiple_metadata(self):
config = {
'trigger': {'misc': {'timeout': '120'}},
'output': {'misc': {'at-sender': 'false'}},
}
meta_trigger = self._make_meta('trigger', 'misc', [{'name': 'timeout', 'type': 'integer'}])
meta_output = self._make_meta('output', 'misc', [{'name': 'at-sender', 'type': 'boolean'}])
coerce_pipeline_config(config, meta_trigger, meta_output)
assert config['trigger']['misc']['timeout'] == 120
assert config['output']['misc']['at-sender'] is False
+359
View File
@@ -0,0 +1,359 @@
"""
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')
+353
View File
@@ -0,0 +1,353 @@
"""
Unit tests for N8nServiceAPIRunner._process_response
Tests cover four scenarios:
- Stream adapter + n8n stream format (type:item/end)
- Stream adapter + n8n plain JSON
- Non-stream adapter + n8n stream format
- Non-stream adapter + n8n plain JSON
"""
from __future__ import annotations
import json
import sys
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
import langbot_plugin.api.entities.builtin.provider.message as provider_message
# Break the circular import chain while importing n8nsvapi:
# n8nsvapi → runner → app → pipelinemgr → all runners → runner (partially init)
# The stubs are restored in a ``finally`` block so this module does NOT pollute
# sys.modules for other test modules (e.g. ones importing the real
# LocalAgentRunner, which would otherwise inherit ``object`` and break).
# Mirrors master's intent but uses try/finally so a raised import doesn't
# leave the global namespace in a stubbed state, and includes
# ``langbot.pkg.utils.httpclient`` which master didn't stub.
_runner_stub = MagicMock()
_runner_stub.runner_class = lambda name: (lambda cls: cls) # no-op decorator
_runner_stub.RequestRunner = object
_import_stubs = {
'langbot.pkg.provider.runner': _runner_stub,
'langbot.pkg.core.app': MagicMock(),
'langbot.pkg.utils.httpclient': MagicMock(),
}
_saved_modules = {name: sys.modules.get(name) for name in _import_stubs}
for _name, _stub in _import_stubs.items():
sys.modules[_name] = _stub
try:
from langbot.pkg.provider.runners.n8nsvapi import N8nServiceAPIRunner
finally:
for _name, _original in _saved_modules.items():
if _original is None:
sys.modules.pop(_name, None)
else:
sys.modules[_name] = _original
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_runner(output_key: str = 'response') -> N8nServiceAPIRunner:
ap = Mock()
ap.logger = Mock()
pipeline_config = {
'ai': {
'n8n-service-api': {
'webhook-url': 'http://test-n8n/webhook',
'output-key': output_key,
'auth-type': 'none',
}
}
}
return N8nServiceAPIRunner(ap, pipeline_config)
def make_mock_response(chunks: list[bytes | str], status: int = 200):
"""Build a minimal aiohttp.ClientResponse mock with iter_chunked support."""
response = Mock()
response.status = status
async def iter_chunked(size):
for chunk in chunks:
yield chunk
response.content = Mock()
response.content.iter_chunked = iter_chunked
return response
async def collect_chunks(runner: N8nServiceAPIRunner, chunks: list[bytes | str]):
"""Run _process_response and collect all yielded MessageChunks."""
response = make_mock_response(chunks)
result = []
async for chunk in runner._process_response(response):
result.append(chunk)
return result
# ---------------------------------------------------------------------------
# _process_response: stream format (type:item/end)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stream_format_single_item():
"""Single item + end in one chunk yields final chunk with full content."""
runner = make_runner()
data = b'{"type":"item","content":"hello"}{"type":"end"}'
chunks = await collect_chunks(runner, [data])
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
async def test_stream_format_multi_item_accumulates():
"""Multiple items accumulate into full_content."""
runner = make_runner()
chunks_data = [
b'{"type":"item","content":"foo"}',
b'{"type":"item","content":"bar"}',
b'{"type":"end"}',
]
chunks = await collect_chunks(runner, chunks_data)
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
async def test_stream_format_batches_every_8_items():
"""Every 8th item triggers an intermediate yield before the final."""
runner = make_runner()
items = [f'{{"type":"item","content":"{i}"}}' for i in range(8)]
items.append('{"type":"end"}')
data = ''.join(items).encode()
chunks = await collect_chunks(runner, [data])
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
async def test_stream_format_split_across_network_chunks():
"""JSON split across multiple network chunks is reassembled correctly."""
runner = make_runner()
part1 = b'{"type":"item","con'
part2 = b'tent":"world"}{"type":"end"}'
chunks = await collect_chunks(runner, [part1, part2])
assert len(chunks) == 1
assert chunks[0].is_final is True
assert chunks[0].content == 'world'
@pytest.mark.asyncio
async def test_stream_format_no_spurious_empty_yield():
"""chunk_idx==0 guard prevents spurious empty yield before any item is received."""
runner = make_runner()
# Send some non-stream JSON first, then stream
data = b'{"type":"item","content":"x"}{"type":"end"}'
chunks = await collect_chunks(runner, [data])
assert len(chunks) == 1
assert chunks[0].content == 'x'
# ---------------------------------------------------------------------------
# _process_response: plain JSON fallback
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_plain_json_with_output_key():
"""Plain JSON with matching output_key extracts value via output_key."""
runner = make_runner(output_key='response')
data = json.dumps({'response': 'hello world'}).encode()
chunks = await collect_chunks(runner, [data])
assert len(chunks) == 1
assert chunks[0].is_final is True
assert chunks[0].content == 'hello world'
@pytest.mark.asyncio
async def test_plain_json_output_key_not_found():
"""Plain JSON without output_key falls back to entire JSON string."""
runner = make_runner(output_key='response')
payload = {'other_key': 'hello'}
data = json.dumps(payload).encode()
chunks = await collect_chunks(runner, [data])
assert len(chunks) == 1
assert chunks[0].is_final is True
assert json.loads(chunks[0].content) == payload
@pytest.mark.asyncio
async def test_plain_json_output_key_empty_string():
"""output_key present but value is empty string — returns empty string, not whole JSON."""
runner = make_runner(output_key='response')
data = json.dumps({'response': ''}).encode()
chunks = await collect_chunks(runner, [data])
assert len(chunks) == 1
assert chunks[0].is_final is True
assert chunks[0].content == ''
@pytest.mark.asyncio
async def test_plain_json_non_dict_response():
"""Plain JSON array falls back to raw text."""
runner = make_runner()
data = b'["a", "b"]'
chunks = await collect_chunks(runner, [data])
assert len(chunks) == 1
assert chunks[0].is_final is True
assert chunks[0].content == '["a", "b"]'
@pytest.mark.asyncio
async def test_invalid_json_returns_raw_text():
"""Non-JSON response returns raw text as-is."""
runner = make_runner()
data = b'plain text response'
chunks = await collect_chunks(runner, [data])
assert len(chunks) == 1
assert chunks[0].is_final is True
assert chunks[0].content == 'plain text response'
# ---------------------------------------------------------------------------
# _call_webhook: output type depends on is_stream
# ---------------------------------------------------------------------------
def make_query(is_stream: bool):
"""Build a minimal Query mock."""
query = Mock()
query.adapter = AsyncMock()
query.adapter.is_stream_output_supported = AsyncMock(return_value=is_stream)
session = Mock()
session.using_conversation = Mock()
session.using_conversation.uuid = 'test-uuid'
session.launcher_type = Mock()
session.launcher_type.value = 'person'
session.launcher_id = '12345'
query.session = session
query.user_message = Mock()
query.user_message.content = 'hi'
query.variables = {}
return query
def make_http_session_mock(response_bytes: bytes, status: int = 200):
"""Mock httpclient.get_session() returning a session whose post() yields response_bytes."""
mock_response = make_mock_response([response_bytes], status=status)
mock_response.status = status
mock_cm = AsyncMock()
mock_cm.__aenter__ = AsyncMock(return_value=mock_response)
mock_cm.__aexit__ = AsyncMock(return_value=False)
mock_session = Mock()
mock_session.post = Mock(return_value=mock_cm)
return mock_session
@pytest.mark.asyncio
async def test_call_webhook_nonstream_adapter_plain_json():
"""Non-stream adapter + plain JSON → single Message with output_key value."""
runner = make_runner(output_key='response')
query = make_query(is_stream=False)
http_session = make_http_session_mock(json.dumps({'response': 'result text'}).encode())
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session):
results = []
async for msg in runner._call_webhook(query):
results.append(msg)
assert len(results) == 1
assert isinstance(results[0], provider_message.Message)
assert results[0].content == 'result text'
@pytest.mark.asyncio
async def test_call_webhook_stream_adapter_stream_format():
"""Stream adapter + stream format → MessageChunks, last is_final."""
runner = make_runner()
query = make_query(is_stream=True)
data = b'{"type":"item","content":"hi"}{"type":"end"}'
http_session = make_http_session_mock(data)
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session):
results = []
async for msg in runner._call_webhook(query):
results.append(msg)
assert all(isinstance(r, provider_message.MessageChunk) for r in results)
assert results[-1].is_final is True
assert results[-1].content == 'hi'
@pytest.mark.asyncio
async def test_call_webhook_stream_adapter_plain_json():
"""Stream adapter + plain JSON → single MessageChunk with is_final=True."""
runner = make_runner(output_key='response')
query = make_query(is_stream=True)
data = json.dumps({'response': 'fallback'}).encode()
http_session = make_http_session_mock(data)
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session):
results = []
async for msg in runner._call_webhook(query):
results.append(msg)
assert all(isinstance(r, provider_message.MessageChunk) for r in results)
assert results[-1].is_final is True
assert results[-1].content == 'fallback'
@pytest.mark.asyncio
async def test_call_webhook_nonstream_adapter_stream_format():
"""Non-stream adapter + stream format → single Message with accumulated content."""
runner = make_runner()
query = make_query(is_stream=False)
data = b'{"type":"item","content":"foo"}{"type":"item","content":"bar"}{"type":"end"}'
http_session = make_http_session_mock(data)
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session):
results = []
async for msg in runner._call_webhook(query):
results.append(msg)
assert len(results) == 1
assert isinstance(results[0], provider_message.Message)
assert results[0].content == 'foobar'
@@ -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)
@@ -0,0 +1,207 @@
"""
PipelineManager unit tests
"""
import pytest
from unittest.mock import AsyncMock, Mock
from importlib import import_module
def get_pipelinemgr_module():
return import_module('langbot.pkg.pipeline.pipelinemgr')
def get_stage_module():
return import_module('langbot.pkg.pipeline.stage')
def get_entities_module():
return import_module('langbot.pkg.pipeline.entities')
def get_persistence_pipeline_module():
return import_module('langbot.pkg.entity.persistence.pipeline')
@pytest.mark.asyncio
async def test_pipeline_manager_initialize(mock_app):
"""Test pipeline manager initialization"""
pipelinemgr = get_pipelinemgr_module()
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(all=Mock(return_value=[])))
manager = pipelinemgr.PipelineManager(mock_app)
await manager.initialize()
assert manager.stage_dict is not None
assert len(manager.pipelines) == 0
@pytest.mark.asyncio
async def test_load_pipeline(mock_app):
"""Test loading a single pipeline"""
pipelinemgr = get_pipelinemgr_module()
persistence_pipeline = get_persistence_pipeline_module()
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(all=Mock(return_value=[])))
manager = pipelinemgr.PipelineManager(mock_app)
await manager.initialize()
# Create test pipeline entity
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
pipeline_entity.uuid = 'test-uuid'
pipeline_entity.stages = []
pipeline_entity.config = {'test': 'config'}
pipeline_entity.extensions_preferences = {'plugins': []}
await manager.load_pipeline(pipeline_entity)
assert len(manager.pipelines) == 1
assert manager.pipelines[0].pipeline_entity.uuid == 'test-uuid'
@pytest.mark.asyncio
async def test_get_pipeline_by_uuid(mock_app):
"""Test getting pipeline by UUID"""
pipelinemgr = get_pipelinemgr_module()
persistence_pipeline = get_persistence_pipeline_module()
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(all=Mock(return_value=[])))
manager = pipelinemgr.PipelineManager(mock_app)
await manager.initialize()
# Create and add test pipeline
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
pipeline_entity.uuid = 'test-uuid'
pipeline_entity.stages = []
pipeline_entity.config = {}
pipeline_entity.extensions_preferences = {'plugins': []}
await manager.load_pipeline(pipeline_entity)
# Test retrieval
result = await manager.get_pipeline_by_uuid('test-uuid')
assert result is not None
assert result.pipeline_entity.uuid == 'test-uuid'
# Test non-existent UUID
result = await manager.get_pipeline_by_uuid('non-existent')
assert result is None
@pytest.mark.asyncio
async def test_remove_pipeline(mock_app):
"""Test removing a pipeline"""
pipelinemgr = get_pipelinemgr_module()
persistence_pipeline = get_persistence_pipeline_module()
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(all=Mock(return_value=[])))
manager = pipelinemgr.PipelineManager(mock_app)
await manager.initialize()
# Create and add test pipeline
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
pipeline_entity.uuid = 'test-uuid'
pipeline_entity.stages = []
pipeline_entity.config = {}
pipeline_entity.extensions_preferences = {'plugins': []}
await manager.load_pipeline(pipeline_entity)
assert len(manager.pipelines) == 1
# Remove pipeline
await manager.remove_pipeline('test-uuid')
assert len(manager.pipelines) == 0
@pytest.mark.asyncio
async def test_runtime_pipeline_execute(mock_app, sample_query):
"""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 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=real_result)
# Create stage container
stage_container = pipelinemgr.StageInstContainer(inst_name='TestStage', inst=mock_stage)
# Create pipeline entity
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
pipeline_entity.config = sample_query.pipeline_config
pipeline_entity.extensions_preferences = {'plugins': []}
# Create runtime pipeline
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [stage_container])
# Mock plugin connector
event_ctx = Mock()
event_ctx.is_prevented_default = Mock(return_value=False)
mock_app.plugin_connector.emit_event = AsyncMock(return_value=event_ctx)
# Add query to cached_queries to prevent KeyError in finally block
mock_app.query_pool.cached_queries[sample_query.query_id] = sample_query
# Execute pipeline
await runtime_pipeline.run(sample_query)
# Verify stage was called
mock_stage.process.assert_called_once()
def test_runtime_pipeline_prefers_local_agent_mcp_resources(mock_app):
"""Local Agent resource selection should override legacy extension prefs."""
pipelinemgr = get_pipelinemgr_module()
persistence_pipeline = get_persistence_pipeline_module()
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
pipeline_entity.config = {
'ai': {
'local-agent': {
'mcp-resources': [{'server_uuid': 'srv-new', 'uri': 'file:///new.md'}],
'mcp-resource-agent-read-enabled': False,
}
}
}
pipeline_entity.extensions_preferences = {
'mcp_resources': [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}],
'mcp_resource_agent_read_enabled': True,
}
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
assert runtime_pipeline.mcp_resource_attachments == [{'server_uuid': 'srv-new', 'uri': 'file:///new.md'}]
assert runtime_pipeline.mcp_resource_agent_read_enabled is False
def test_runtime_pipeline_falls_back_to_extension_mcp_resources(mock_app):
"""Existing extension prefs remain compatible until a Local Agent value exists."""
pipelinemgr = get_pipelinemgr_module()
persistence_pipeline = get_persistence_pipeline_module()
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
pipeline_entity.config = {'ai': {'local-agent': {}}}
pipeline_entity.extensions_preferences = {
'mcp_resources': [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}],
'mcp_resource_agent_read_enabled': False,
}
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
assert runtime_pipeline.mcp_resource_attachments == [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}]
assert runtime_pipeline.mcp_resource_agent_read_enabled is False
+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]
+491
View File
@@ -0,0 +1,491 @@
"""
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 types import SimpleNamespace
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
class TestPreProcessorToolSelection:
"""Tests for Local Agent tool selection."""
@pytest.mark.asyncio
async def test_local_agent_filters_selected_tools(self):
"""Only selected tools should be exposed when all-tools mode is off."""
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=[
SimpleNamespace(name='exec'),
SimpleNamespace(name='plugin_tool'),
SimpleNamespace(name='mcp_tool'),
]
)
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-model-uuid', 'fallbacks': []},
'prompt': 'default',
'enable-all-tools': False,
'tools': ['plugin_tool'],
},
},
'output': {'misc': {'at-sender': False}},
'trigger': {'misc': {}},
}
result = await stage.process(query, 'PreProcessor')
assert [tool.name for tool in result.new_query.use_funcs] == ['plugin_tool']
@@ -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}
+341
View File
@@ -0,0 +1,341 @@
"""
RateLimit stage unit tests
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
def get_modules():
"""Lazy import to ensure proper initialization order"""
# Import pipelinemgr first to trigger proper stage registration
ratelimit = import_module('langbot.pkg.pipeline.ratelimit.ratelimit')
entities = import_module('langbot.pkg.pipeline.entities')
algo_module = import_module('langbot.pkg.pipeline.ratelimit.algo')
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"""
ratelimit, entities, algo_module = get_modules()
sample_query.launcher_type = provider_session.LauncherTypes.PERSON
sample_query.launcher_id = '12345'
sample_query.pipeline_config = {}
# Create mock algorithm that allows access
mock_algo = Mock(spec=algo_module.ReteLimitAlgo)
mock_algo.require_access = AsyncMock(return_value=True)
mock_algo.initialize = AsyncMock()
stage = ratelimit.RateLimit(mock_app)
# Patch the algorithm selection to use our mock
with patch.object(algo_module, 'preregistered_algos', []):
stage.algo = mock_algo
result = await stage.process(sample_query, 'RequireRateLimitOccupancy')
assert result.result_type == entities.ResultType.CONTINUE
assert result.new_query == sample_query
mock_algo.require_access.assert_called_once_with(sample_query, 'person', '12345')
@pytest.mark.asyncio
async def test_require_access_denied(mock_app, sample_query):
"""Test RequireRateLimitOccupancy denies access when rate limit is exceeded"""
ratelimit, entities, algo_module = get_modules()
sample_query.launcher_type = provider_session.LauncherTypes.PERSON
sample_query.launcher_id = '12345'
sample_query.pipeline_config = {}
# Create mock algorithm that denies access
mock_algo = Mock(spec=algo_module.ReteLimitAlgo)
mock_algo.require_access = AsyncMock(return_value=False)
mock_algo.initialize = AsyncMock()
stage = ratelimit.RateLimit(mock_app)
# Patch the algorithm selection to use our mock
with patch.object(algo_module, 'preregistered_algos', []):
stage.algo = mock_algo
result = await stage.process(sample_query, 'RequireRateLimitOccupancy')
assert result.result_type == entities.ResultType.INTERRUPT
assert result.user_notice != ''
mock_algo.require_access.assert_called_once()
@pytest.mark.asyncio
async def test_release_access(mock_app, sample_query):
"""Test ReleaseRateLimitOccupancy releases rate limit occupancy"""
ratelimit, entities, algo_module = get_modules()
sample_query.launcher_type = provider_session.LauncherTypes.PERSON
sample_query.launcher_id = '12345'
sample_query.pipeline_config = {}
# Create mock algorithm
mock_algo = Mock(spec=algo_module.ReteLimitAlgo)
mock_algo.release_access = AsyncMock()
mock_algo.initialize = AsyncMock()
stage = ratelimit.RateLimit(mock_app)
# Patch the algorithm selection to use our mock
with patch.object(algo_module, 'preregistered_algos', []):
stage.algo = mock_algo
result = await stage.process(sample_query, 'ReleaseRateLimitOccupancy')
assert result.result_type == entities.ResultType.CONTINUE
assert result.new_query == sample_query
mock_algo.release_access.assert_called_once_with(sample_query, 'person', '12345')
+140
View File
@@ -0,0 +1,140 @@
"""
GroupRespondRuleCheckStage unit tests
Tests the actual GroupRespondRuleCheckStage implementation from pkg.pipeline.resprule
"""
import pytest
from unittest.mock import AsyncMock, Mock
from importlib import import_module
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.platform.message as platform_message
def get_modules():
"""Lazy import to ensure proper initialization order"""
# Import pipelinemgr first to trigger proper stage registration
# pipelinemgr = import_module('langbot.pkg.pipeline.pipelinemgr')
resprule = import_module('langbot.pkg.pipeline.resprule.resprule')
entities = import_module('langbot.pkg.pipeline.entities')
rule = import_module('langbot.pkg.pipeline.resprule.rule')
rule_entities = import_module('langbot.pkg.pipeline.resprule.entities')
return resprule, entities, rule, rule_entities
@pytest.mark.asyncio
async def test_person_message_skip(mock_app, sample_query):
"""Test person message skips rule check"""
resprule, entities, rule, rule_entities = get_modules()
sample_query.launcher_type = provider_session.LauncherTypes.PERSON
sample_query.pipeline_config = {'trigger': {'group-respond-rules': {}}}
stage = resprule.GroupRespondRuleCheckStage(mock_app)
await stage.initialize(sample_query.pipeline_config)
result = await stage.process(sample_query, 'GroupRespondRuleCheckStage')
assert result.result_type == entities.ResultType.CONTINUE
assert result.new_query == sample_query
@pytest.mark.asyncio
async def test_group_message_no_match(mock_app, sample_query):
"""Test group message with no matching rules"""
resprule, entities, rule, rule_entities = get_modules()
sample_query.launcher_type = provider_session.LauncherTypes.GROUP
sample_query.launcher_id = '12345'
sample_query.pipeline_config = {'trigger': {'group-respond-rules': {}}}
# Create mock rule matcher that doesn't match
mock_rule = Mock(spec=rule.GroupRespondRule)
mock_rule.match = AsyncMock(
return_value=rule_entities.RuleJudgeResult(matching=False, replacement=sample_query.message_chain)
)
stage = resprule.GroupRespondRuleCheckStage(mock_app)
await stage.initialize(sample_query.pipeline_config)
stage.rule_matchers = [mock_rule]
result = await stage.process(sample_query, 'GroupRespondRuleCheckStage')
assert result.result_type == entities.ResultType.INTERRUPT
assert result.new_query == sample_query
mock_rule.match.assert_called_once()
@pytest.mark.asyncio
async def test_group_message_match(mock_app, sample_query):
"""Test group message with matching rule"""
resprule, entities, rule, rule_entities = get_modules()
sample_query.launcher_type = provider_session.LauncherTypes.GROUP
sample_query.launcher_id = '12345'
sample_query.pipeline_config = {'trigger': {'group-respond-rules': {}}}
# Create new message chain after rule processing
new_chain = platform_message.MessageChain([platform_message.Plain(text='Processed message')])
# Create mock rule matcher that matches
mock_rule = Mock(spec=rule.GroupRespondRule)
mock_rule.match = AsyncMock(return_value=rule_entities.RuleJudgeResult(matching=True, replacement=new_chain))
stage = resprule.GroupRespondRuleCheckStage(mock_app)
await stage.initialize(sample_query.pipeline_config)
stage.rule_matchers = [mock_rule]
result = await stage.process(sample_query, 'GroupRespondRuleCheckStage')
assert result.result_type == entities.ResultType.CONTINUE
assert result.new_query == sample_query
assert sample_query.message_chain == new_chain
mock_rule.match.assert_called_once()
@pytest.mark.asyncio
async def test_atbot_rule_match(mock_app, sample_query):
"""Test AtBotRule removes At component"""
resprule, entities, rule, rule_entities = get_modules()
atbot_module = import_module('langbot.pkg.pipeline.resprule.rules.atbot')
sample_query.launcher_type = provider_session.LauncherTypes.GROUP
sample_query.adapter.bot_account_id = '999'
# Create message chain with At component
message_chain = platform_message.MessageChain(
[platform_message.At(target='999'), platform_message.Plain(text='Hello bot')]
)
sample_query.message_chain = message_chain
atbot_rule = atbot_module.AtBotRule(mock_app)
await atbot_rule.initialize()
result = await atbot_rule.match(str(message_chain), message_chain, {}, sample_query)
assert result.matching is True
# At component should be removed
assert len(result.replacement.root) == 1
assert isinstance(result.replacement.root[0], platform_message.Plain)
@pytest.mark.asyncio
async def test_atbot_rule_no_match(mock_app, sample_query):
"""Test AtBotRule when no At component present"""
resprule, entities, rule, rule_entities = get_modules()
atbot_module = import_module('langbot.pkg.pipeline.resprule.rules.atbot')
sample_query.launcher_type = provider_session.LauncherTypes.GROUP
sample_query.adapter.bot_account_id = '999'
# Create message chain without At component
message_chain = platform_message.MessageChain([platform_message.Plain(text='Hello')])
sample_query.message_chain = message_chain
atbot_rule = atbot_module.AtBotRule(mock_app)
await atbot_rule.initialize()
result = await atbot_rule.match(str(message_chain), message_chain, {}, sample_query)
assert result.matching is False
+616
View File
@@ -0,0 +1,616 @@
"""
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 get_plugin_diagnostics_module():
"""Lazy import for plugin diagnostic attribution helpers."""
return import_module('langbot.pkg.pipeline.plugin_diagnostics')
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
@pytest.mark.asyncio
async def test_message_chain_direct_append_consumes_pending_plugin_source(self):
"""MessageChain replies from earlier plugin events keep attribution."""
wrapper = get_wrapper_module()
app = FakeApp()
stage = wrapper.ResponseWrapper(app)
await stage.initialize(make_wrapper_config())
reply_chain = platform_message.MessageChain([platform_message.Plain(text='response')])
query = text_query('hello')
query.pipeline_config = make_wrapper_config()
query.resp_messages = [reply_chain]
query.resp_message_chain = []
plugin_diagnostics = get_plugin_diagnostics_module()
plugin_diagnostics.record_pending_plugin_response_source(
query,
reply_chain,
[
{
'kind': 'reply_message_chain',
'plugin': {'author': 'tester', 'name': 'demo'},
}
],
[{'manifest': {'metadata': {'author': 'observer', 'name': 'not-reply-source'}}}],
'PersonNormalMessageReceived',
)
results = []
async for result in stage.process(query, 'ResponseWrapper'):
results.append(result)
sources = plugin_diagnostics._get_response_sources(results[0].new_query, 0)
assert sources[0].plugin == {'author': 'tester', 'name': 'demo'}
assert sources[0].event_name == 'PersonNormalMessageReceived'
assert sources[0].is_approximate is False
assert '_plugin_response_sources' not in query.variables
assert '_plugin_pending_response_sources' not in query.variables
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)
@pytest.mark.asyncio
async def test_custom_reply_records_plugin_source(self):
"""Plugin reply_message_chain should keep emitted plugin attribution."""
wrapper = get_wrapper_module()
app = FakeApp()
app.sess_mgr.get_session = AsyncMock(return_value=make_session())
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
mock_event_ctx._emitted_plugins = [
{
'manifest': {'metadata': {'author': 'observer', 'name': 'not-reply-source'}},
'plugin_config': {'token': 'secret-token'},
},
]
mock_event_ctx._response_sources = [
{
'kind': 'reply_message_chain',
'plugin': {'author': 'tester', 'name': 'demo'},
}
]
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 = []
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)
plugin_diagnostics = get_plugin_diagnostics_module()
sources = plugin_diagnostics._get_response_sources(results[0].new_query, 0)
assert sources[0].plugin == {'author': 'tester', 'name': 'demo'}
assert sources[0].event_name == 'NormalMessageResponded'
assert sources[0].is_approximate is False
assert 'secret-token' not in str(sources)
assert '_plugin_response_sources' not in query.variables
@pytest.mark.asyncio
async def test_custom_reply_falls_back_to_emitted_plugins_for_old_runtime(self):
"""Older plugin runtimes without response_sources keep approximate attribution."""
wrapper = get_wrapper_module()
app = FakeApp()
app.sess_mgr.get_session = AsyncMock(return_value=make_session())
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
mock_event_ctx._emitted_plugins = [
{'manifest': {'metadata': {'author': 'tester', 'name': 'demo'}}},
]
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 = []
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)
plugin_diagnostics = get_plugin_diagnostics_module()
sources = plugin_diagnostics._get_response_sources(results[0].new_query, 0)
assert sources[0].plugin == {'author': 'tester', 'name': 'demo'}
assert sources[0].is_approximate is True
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,146 @@
"""Unit tests for ResponseWrapper outbound-attachment helpers.
Covers the sandbox -> user attachment path added for the Box attachment
round-trip:
* ``_is_final_assistant_message`` — only the terminal, tool-call-free assistant
message (or a final MessageChunk) should trigger collection.
* ``_append_outbound_attachments`` — collects sandbox outbox files exactly once
per query and maps each descriptor to the right platform component, swallowing
collection errors.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.provider.message as provider_message
from langbot.pkg.pipeline.wrapper.wrapper import ResponseWrapper
def _make_wrapper(box_service) -> ResponseWrapper:
app = SimpleNamespace(logger=Mock())
wrapper = ResponseWrapper.__new__(ResponseWrapper)
wrapper.ap = app
return wrapper
def _make_query():
return SimpleNamespace(variables={})
def test_is_final_assistant_message_plain_assistant():
wrapper = _make_wrapper(box_service=None)
msg = provider_message.Message(role='assistant', content='done')
assert wrapper._is_final_assistant_message(msg) is True
def test_is_final_assistant_message_rejects_non_assistant():
wrapper = _make_wrapper(box_service=None)
msg = provider_message.Message(role='tool', content='{}')
assert wrapper._is_final_assistant_message(msg) is False
def test_is_final_assistant_message_rejects_tool_call_round():
wrapper = _make_wrapper(box_service=None)
msg = provider_message.Message(
role='assistant',
content='calling',
tool_calls=[
provider_message.ToolCall(
id='c1',
type='function',
function=provider_message.FunctionCall(name='exec', arguments='{}'),
)
],
)
assert wrapper._is_final_assistant_message(msg) is False
def test_is_final_assistant_message_non_final_chunk():
wrapper = _make_wrapper(box_service=None)
chunk = provider_message.MessageChunk(role='assistant', content='partial', is_final=False)
assert wrapper._is_final_assistant_message(chunk) is False
final_chunk = provider_message.MessageChunk(role='assistant', content='partial', is_final=True)
assert wrapper._is_final_assistant_message(final_chunk) is True
@pytest.mark.asyncio
async def test_append_outbound_attachments_maps_each_type():
box_service = SimpleNamespace(
available=True,
collect_outbound_attachments=AsyncMock(
return_value=[
{'type': 'Image', 'base64': 'data:image/png;base64,iVBORw0K'},
{'type': 'Voice', 'base64': 'data:audio/wav;base64,UklGRg=='},
{'type': 'File', 'name': 'report.xlsx', 'base64': 'data:app;base64,UEsDBA=='},
]
),
)
wrapper = _make_wrapper(box_service)
wrapper.ap.box_service = box_service
query = _make_query()
chain = platform_message.MessageChain([])
await wrapper._append_outbound_attachments(query, chain)
kinds = [type(c).__name__ for c in chain]
assert kinds == ['Image', 'Voice', 'File']
assert query.variables['_sandbox_outbound_collected'] is True
# File keeps its name
file_comp = chain[2]
assert getattr(file_comp, 'name', None) == 'report.xlsx'
@pytest.mark.asyncio
async def test_append_outbound_attachments_runs_once_per_query():
box_service = SimpleNamespace(
available=True,
collect_outbound_attachments=AsyncMock(return_value=[]),
)
wrapper = _make_wrapper(box_service)
wrapper.ap.box_service = box_service
query = _make_query()
query.variables['_sandbox_outbound_collected'] = True
chain = platform_message.MessageChain([])
await wrapper._append_outbound_attachments(query, chain)
box_service.collect_outbound_attachments.assert_not_awaited()
assert len(chain) == 0
@pytest.mark.asyncio
async def test_append_outbound_attachments_noop_without_box_service():
wrapper = _make_wrapper(box_service=None)
wrapper.ap.box_service = None
query = _make_query()
chain = platform_message.MessageChain([])
await wrapper._append_outbound_attachments(query, chain)
assert len(chain) == 0
# not marked collected, since service is unavailable
assert '_sandbox_outbound_collected' not in query.variables
@pytest.mark.asyncio
async def test_append_outbound_attachments_swallows_collection_error():
box_service = SimpleNamespace(
available=True,
collect_outbound_attachments=AsyncMock(side_effect=RuntimeError('boom')),
)
wrapper = _make_wrapper(box_service)
wrapper.ap.box_service = box_service
query = _make_query()
chain = platform_message.MessageChain([])
# must not raise
await wrapper._append_outbound_attachments(query, chain)
assert len(chain) == 0
wrapper.ap.logger.warning.assert_called_once()
@@ -0,0 +1,538 @@
import pytest
import aiocqhttp
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.pkg.platform.sources.aiocqhttp import (
AiocqhttpAdapter,
AiocqhttpEventConverter,
AiocqhttpMessageConverter,
)
async def _convert_single(component: platform_message.MessageComponent):
chain = platform_message.MessageChain([component])
message, _, _ = await AiocqhttpMessageConverter.yiri2target(chain)
return message[0]
@pytest.mark.asyncio
@pytest.mark.parametrize(
('payload', 'expected'),
[
('data:image/jpeg;base64,raw-image', 'base64://raw-image'),
('raw-image', 'base64://raw-image'),
('base64://raw-image', 'base64://raw-image'),
],
)
async def test_image_base64_payload_is_normalized(payload, expected):
segment = await _convert_single(platform_message.Image(base64=payload))
assert segment.type == 'image'
assert segment.data['file'] == expected
@pytest.mark.asyncio
async def test_voice_data_uri_base64_payload_is_normalized():
segment = await _convert_single(platform_message.Voice(base64='data:audio/wav;base64,raw-voice'))
assert segment.type == 'record'
assert segment.data['file'] == 'base64://raw-voice'
@pytest.mark.asyncio
@pytest.mark.parametrize(
('component', 'expected'),
[
(
platform_message.File(name='report.txt', base64='data:text/plain;base64,raw-file'),
{'file': 'base64://raw-file', 'name': 'report.txt'},
),
(
platform_message.File(name='report.txt', base64='raw-file'),
{'file': 'base64://raw-file', 'name': 'report.txt'},
),
(
platform_message.File(name='a.txt', url='http://example.com/a.txt'),
{'file': 'http://example.com/a.txt', 'name': 'a.txt'},
),
(
platform_message.File(name='a.txt', path='/tmp/a.txt'),
{'file': '/tmp/a.txt', 'name': 'a.txt'},
),
],
)
async def test_file_message_uses_available_file_source(component, expected):
segment = await _convert_single(component)
assert segment.type == 'file'
assert segment.data == expected
@pytest.mark.asyncio
async def test_forward_image_base64_payload_is_normalized():
forward = platform_message.Forward(
node_list=[
platform_message.ForwardMessageNode(
sender_id='10001',
sender_name='Tester',
message_chain=platform_message.MessageChain(
[platform_message.Image(base64='data:image/png;base64,raw-forward-image')]
),
)
]
)
messages = []
class Logger:
async def info(self, _message):
return None
async def error(self, _message):
return None
class Bot:
async def call_action(self, action, **kwargs):
assert action == 'send_forward_msg'
messages.append(kwargs)
platform = AiocqhttpAdapter.model_construct(
bot_account_id='10000',
config={},
logger=Logger(),
bot=Bot(),
)
await platform._send_forward_message(1000, forward)
assert messages[0]['messages'][0]['data']['content'][0] == {
'type': 'image',
'data': {'file': 'base64://raw-forward-image'},
}
@pytest.mark.asyncio
async def test_group_message_member_name_prefers_group_card():
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
'title': 'Special Title',
},
}
)
class Bot:
async def get_group_info(self, group_id):
assert group_id == 2000
return {'group_id': group_id, 'group_name': 'Test Group'}
converted = await AiocqhttpEventConverter().target2yiri(event, Bot())
assert converted.sender.member_name == 'Group Card'
assert converted.sender.group.id == 2000
assert converted.sender.group.name == 'Test Group'
assert converted.sender.special_title == 'Special Title'
@pytest.mark.asyncio
async def test_group_message_member_name_falls_back_to_nickname():
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': '',
'role': 'member',
},
}
)
converted = await AiocqhttpEventConverter().target2yiri(event)
assert converted.sender.member_name == 'QQ Nickname'
@pytest.mark.asyncio
async def test_group_message_special_title_uses_group_member_info_when_sender_title_is_empty():
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
'title': '',
},
}
)
class Bot:
async def get_group_info(self, group_id):
return {'group_id': group_id, 'group_name': 'Test Group'}
async def get_group_member_info(self, group_id, user_id):
assert group_id == 2000
assert user_id == 3000
return {'group_id': group_id, 'user_id': user_id, 'title': 'Member Title'}
converted = await AiocqhttpEventConverter().target2yiri(event, Bot())
assert converted.sender.special_title == 'Member Title'
@pytest.mark.asyncio
async def test_group_message_special_title_does_not_lookup_when_sender_title_exists():
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
'title': 'Event Title',
},
}
)
class Bot:
async def get_group_info(self, group_id):
return {'group_id': group_id, 'group_name': 'Test Group'}
async def get_group_member_info(self, group_id, user_id):
raise AssertionError('get_group_member_info should not be called')
converted = await AiocqhttpEventConverter().target2yiri(event, Bot())
assert converted.sender.special_title == 'Event Title'
@pytest.mark.asyncio
async def test_group_message_special_title_member_info_failure_is_cached(monkeypatch):
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
'title': '',
},
}
)
now = 1000.0
class Bot:
member_info_calls = 0
async def get_group_info(self, group_id):
return {'group_id': group_id, 'group_name': 'Test Group'}
async def get_group_member_info(self, group_id, user_id):
self.member_info_calls += 1
raise RuntimeError('api unavailable')
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
bot = Bot()
converter = AiocqhttpEventConverter()
first = await converter.target2yiri(event, bot)
second = await converter.target2yiri(event, bot)
assert first.sender.special_title == ''
assert second.sender.special_title == ''
assert bot.member_info_calls == 1
@pytest.mark.asyncio
async def test_group_message_special_title_member_info_cache_expires(monkeypatch):
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
'title': '',
},
}
)
now = 1000.0
class Bot:
member_info_calls = 0
async def get_group_info(self, group_id):
return {'group_id': group_id, 'group_name': 'Test Group'}
async def get_group_member_info(self, group_id, user_id):
self.member_info_calls += 1
return {
'group_id': group_id,
'user_id': user_id,
'title': f'Member Title {self.member_info_calls}',
}
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
bot = Bot()
converter = AiocqhttpEventConverter()
first = await converter.target2yiri(event, bot)
now = 87401.0
second = await converter.target2yiri(event, bot)
assert first.sender.special_title == 'Member Title 1'
assert second.sender.special_title == 'Member Title 2'
assert bot.member_info_calls == 2
@pytest.mark.asyncio
async def test_group_message_special_title_retries_after_negative_cache_expires(monkeypatch):
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
'title': '',
},
}
)
now = 1000.0
class Bot:
member_info_calls = 0
async def get_group_info(self, group_id):
return {'group_id': group_id, 'group_name': 'Test Group'}
async def get_group_member_info(self, group_id, user_id):
self.member_info_calls += 1
if self.member_info_calls == 1:
raise RuntimeError('api unavailable')
return {'group_id': group_id, 'user_id': user_id, 'title': 'Recovered Title'}
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
bot = Bot()
converter = AiocqhttpEventConverter()
failed = await converter.target2yiri(event, bot)
now = 1601.0
recovered = await converter.target2yiri(event, bot)
assert failed.sender.special_title == ''
assert recovered.sender.special_title == 'Recovered Title'
assert bot.member_info_calls == 2
@pytest.mark.asyncio
async def test_group_message_group_name_is_cached(monkeypatch):
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
},
}
)
class Bot:
calls = 0
async def get_group_info(self, group_id):
self.calls += 1
assert group_id == 2000
return {'group_id': group_id, 'group_name': 'Cached Group'}
monotonic = 1000.0
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: monotonic)
bot = Bot()
converter = AiocqhttpEventConverter()
first = await converter.target2yiri(event, bot)
second = await converter.target2yiri(event, bot)
assert first.sender.group.name == 'Cached Group'
assert second.sender.group.name == 'Cached Group'
assert bot.calls == 1
@pytest.mark.asyncio
async def test_group_message_group_name_cache_expires(monkeypatch):
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
},
}
)
now = 1000.0
class Bot:
calls = 0
async def get_group_info(self, group_id):
self.calls += 1
return {'group_id': group_id, 'group_name': f'Group Name {self.calls}'}
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
bot = Bot()
converter = AiocqhttpEventConverter()
first = await converter.target2yiri(event, bot)
now = 4601.0
second = await converter.target2yiri(event, bot)
assert first.sender.group.name == 'Group Name 1'
assert second.sender.group.name == 'Group Name 2'
assert bot.calls == 2
@pytest.mark.asyncio
async def test_group_message_group_name_uses_placeholder_when_lookup_fails(monkeypatch):
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
},
}
)
now = 1000.0
class Bot:
calls = 0
async def get_group_info(self, group_id):
self.calls += 1
raise RuntimeError('api unavailable')
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
bot = Bot()
converter = AiocqhttpEventConverter()
converted = await converter.target2yiri(event, bot)
cached_failure = await converter.target2yiri(event, bot)
assert converted.sender.group.name == 'Group 2000'
assert cached_failure.sender.group.name == 'Group 2000'
assert bot.calls == 1
@pytest.mark.asyncio
async def test_group_message_group_name_retries_after_negative_cache_expires(monkeypatch):
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
},
}
)
now = 1000.0
class Bot:
calls = 0
async def get_group_info(self, group_id):
self.calls += 1
if self.calls == 1:
raise RuntimeError('api unavailable')
return {'group_id': group_id, 'group_name': 'Recovered Group'}
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
bot = Bot()
converter = AiocqhttpEventConverter()
failed = await converter.target2yiri(event, bot)
now = 1061.0
recovered = await converter.target2yiri(event, bot)
assert failed.sender.group.name == 'Group 2000'
assert recovered.sender.group.name == 'Recovered Group'
assert bot.calls == 2
@@ -0,0 +1,254 @@
"""Tests for DingTalk adapter helper behavior."""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from langbot.pkg.platform.sources.dingtalk import (
DingTalkAdapter,
_dingtalk_card_markdown,
_dingtalk_clean_form_content,
_dingtalk_completed_input_lines,
_dingtalk_extract_component_inputs,
_dingtalk_form_component_params,
_dingtalk_missing_completed_input_lines,
_dingtalk_pending_input_defs,
)
def test_dingtalk_select_component_params_expose_options():
params = _dingtalk_form_component_params(
{
'_current_input_field': 'choice',
'input_defs': [
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
}
],
'inputs': {},
}
)
assert params['select_visible'] == 'true'
assert params['select_placeholder'] == 'choice'
assert params['select_options'] == ['A', 'B']
assert [option['value'] for option in params['index_o']] == ['A', 'B']
assert [option['value'] for option in params['test_index']] == ['A', 'B']
assert params['index_o'][0]['text']['zh_CN'] == 'A'
assert params['index_o'][0]['text']['en_US'] == 'A'
assert params['select_index'] == -1
def test_dingtalk_extract_select_from_builtin_result_dict():
inputs = _dingtalk_extract_component_inputs({'selectResult': {'index': 1, 'value': 'B'}})
assert inputs == {'select': 'B'}
def test_dingtalk_extract_select_from_template_param_string():
inputs = _dingtalk_extract_component_inputs({'select': '{"index": 1, "value": "B"}'})
assert inputs == {'select': '{"index": 1, "value": "B"}'}
def test_dingtalk_extract_input_and_select_together():
inputs = _dingtalk_extract_component_inputs(
{
'inputResult': {'value': 'looks good'},
'__built_in_selectResult__': {'index': 0, 'value': 'A'},
}
)
assert inputs == {'input': 'looks good', 'select': 'A'}
def test_dingtalk_extract_component_inputs_strips_card_line_endings():
inputs = _dingtalk_extract_component_inputs(
{
'inputResult': {'value': '回复我测试\r\n'},
'selectResult': {'value': '1\r'},
}
)
assert inputs == {'input': '回复我测试', 'select': '1'}
def test_dingtalk_pending_input_defs_includes_file_fields():
pending = _dingtalk_pending_input_defs(
{
'input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{'output_variable_name': 'files', 'type': 'file-list'},
],
'inputs': {'comment': 'ready'},
}
)
assert [field['output_variable_name'] for field in pending] == ['files']
def test_dingtalk_completed_input_lines_include_text_and_select_values():
lines = _dingtalk_completed_input_lines(
{
'all_input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
},
],
'inputs': {'comment': 'looks good', 'choice': 'B'},
}
)
assert lines == ['✅ commentlooks good', '✅ choiceB']
def test_dingtalk_completed_inputs_are_not_repeated_when_already_interleaved():
form_data = {
'all_input_defs': [
{'output_variable_name': 'us_input', 'type': 'paragraph'},
{'output_variable_name': 'xiala', 'type': 'select'},
],
'inputs': {'us_input': '回复我测试\r', 'xiala': '1'},
}
form_content = '你好\n请输入你的问题\n✅ us_input:回复我测试\n请选择你的答案\n✅ xiala1'
assert _dingtalk_missing_completed_input_lines(form_data, form_content) == []
def test_dingtalk_completed_inputs_are_appended_when_template_does_not_render_them():
form_data = {
'all_input_defs': [{'output_variable_name': 'comment', 'type': 'paragraph'}],
'inputs': {'comment': 'ready'},
}
assert _dingtalk_missing_completed_input_lines(form_data, 'Please review') == ['✅ commentready']
def test_dingtalk_clean_form_content_uses_all_input_defs():
content = _dingtalk_clean_form_content(
{
'raw_form_content': 'Hello\n\n{{#$output.comment#}}\n\n{{#$output.choice#}}\n',
'input_defs': [],
'all_input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{'output_variable_name': 'choice', 'type': 'select'},
],
}
)
assert content == 'Hello'
def test_dingtalk_field_stage_keeps_prior_prompts_and_completed_values():
content = _dingtalk_clean_form_content(
{
'_current_input_field': 'choice',
'raw_form_content': ('Question\n{{#$output.comment#}}\nChoose an answer\n{{#$output.choice#}}'),
'form_content': 'Choose an answer',
'input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{'output_variable_name': 'choice', 'type': 'select'},
],
'inputs': {'comment': 'hello'},
}
)
assert '{{#$output.' not in content
assert content.index('Question') < content.index('comment')
assert content.index('comment') < content.index('Choose an answer')
def test_dingtalk_final_action_stage_interleaves_prompts_and_completed_values():
content = _dingtalk_clean_form_content(
{
'_action_select_only': True,
'raw_form_content': ('11\nQuestion\n{{#$output.comment#}}\nChoose an answer\n{{#$output.choice#}}'),
'all_input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{'output_variable_name': 'choice', 'type': 'select'},
],
'inputs': {'comment': 'hello', 'choice': 'B'},
}
)
assert '{{#$output.' not in content
assert content.startswith('11\nQuestion')
assert content.index('Question') < content.index('comment')
assert content.index('comment') < content.index('Choose an answer')
assert content.index('Choose an answer') < content.index('choice')
def test_dingtalk_card_markdown_preserves_internal_line_breaks():
assert _dingtalk_card_markdown('11\nQuestion\nCompleted') == '11<br>Question<br>Completed'
def _build_card_action_adapter() -> DingTalkAdapter:
adapter = DingTalkAdapter.model_construct(
card_state={
'card-1': {
'session_key': 'group_group-1',
'launcher_type': 'group',
'launcher_id': 'group-1',
'sender_user_id': 'initiator-1',
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'actions': [{'id': 'approve', 'title': 'Approve'}],
'node_title': 'Review',
'form_content': 'Please review',
'input_defs': [],
'inputs': {},
}
},
active_turn_card={},
active_turn_text={},
)
adapter.logger = AsyncMock()
adapter.ap = MagicMock()
adapter.ap.platform_mgr.bots = []
adapter.ap.query_pool.add_query = AsyncMock()
return adapter
@pytest.mark.asyncio
async def test_dingtalk_group_card_action_uses_clicker_as_sender():
adapter = _build_card_action_adapter()
with patch.object(DingTalkAdapter, '_mark_card_resolved', new=AsyncMock()) as mark_resolved:
await adapter._on_card_action(
{
'out_track_id': 'card-1',
'user_id': 'reviewer-2',
'action_id': 'approve',
'params': {},
}
)
await asyncio.sleep(0)
call = adapter.ap.query_pool.add_query.await_args
assert call.kwargs['launcher_id'] == 'group-1'
assert call.kwargs['sender_id'] == 'reviewer-2'
assert call.kwargs['message_event'].sender.id == 'reviewer-2'
mark_resolved.assert_awaited_once()
@pytest.mark.asyncio
async def test_dingtalk_unknown_card_action_is_rejected():
adapter = _build_card_action_adapter()
await adapter._on_card_action(
{
'out_track_id': 'card-1',
'user_id': 'reviewer-2',
'action_id': 'not-on-card',
'params': {},
}
)
adapter.ap.query_pool.add_query.assert_not_awaited()
adapter.logger.warning.assert_awaited_once()

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