Feat/test build (#2174)

* fix(ci): update unit-test workflow paths to match current source layout

Replace stale pkg/** filter with src/langbot/** and add uv.lock.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(tests): update README to reflect current test layout

- Fix stale paths: tests/pipeline → tests/unit_tests/pipeline
- Update CI Python versions: 3.11, 3.12, 3.13
- Add test directory structure for box, config, platform, plugin, provider, storage
- Document pytest markers and uv commands
- Mention planned E2E tests

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(test): add shared test factories package

Create tests/factories/ with reusable test factories:
- FakeApp: mock application with all dependencies
- Message chains: text_chain, mention_chain, image_chain
- Query factories: text_query, group_text_query, command_query, etc.

No test changes - maintains backward compatibility.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(test): add fake provider factory

Add tests/factories/provider.py with:
- FakeProvider: deterministic fake LLM provider
- Error simulation: timeout, auth, rate-limit, malformed
- Request capture for assertions
- fake_model: mock model with attached provider

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(test): add fake platform factory

Add tests/factories/platform.py with:
- FakePlatform: simulated platform adapter
- Inbound message construction: friend/group/image
- Mention-bot flag simulation
- Outbound message capture for assertions
- Streaming output support simulation
- Send failure simulation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(test): add comprehensive message/query factories

Extend tests/factories/message.py with:
- file_query: file attachment query
- unsupported_query: unknown message segment
- voice_query: audio/voice query
- at_all_query: group @All mention
- query_with_session: query with session object
- query_with_config: query with custom pipeline config

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(test): add fake message flow smoke test

Create tests/smoke/test_fake_message_flow.py:
- TestFakeMessageFlow: factory verification tests
- TestMessageFlowIntegration: minimal flow smoke test
- Tests FakeApp, FakeProvider, FakePlatform, query factories
- Verifies LANGBOT_FAKE_PONG marker response
- Captures outbound messages for assertions

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(test): add developer test-quick command

Add scripts/test-quick.sh and Makefile with:
- test-quick: runs ruff check + unit tests + smoke tests
- No real provider keys or platform accounts required
- Suitable for local branch self-test

Update tests/README.md:
- Document test-quick command
- Document test factories package
- Add smoke tests and factories directory structure

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(test): make test-quick reliable as developer gate

Fixes for D-001验收问题:
1. test-quick.sh: use set -euo pipefail, uv run ruff, no tail pipe
2. Remove unused imports in factories (app.py, platform.py, provider.py)
3. Fix unused variable in smoke test
4. Add noqa: E402 to test_n8nsvapi.py lazy imports
5. Update smoke test docs: "minimal fake flow" not full pipeline

Now test-quick is a reliable gate: lint failures exit 1, test failures propagate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(unit): add preproc and taskmgr unit tests

U-001: Pipeline Preprocessor tests
- Normal text message processing
- Empty message handling
- Image segment with/without vision model
- Model selection and fallback
- Variable extraction

U-004: Core Task Manager tests (pattern-based)
- Task creation and tracking patterns
- Task cancellation patterns
- Scope-based cancellation
- Task type filtering
- Pruning completed tasks
- Wait all tasks

Taskmgr tests use pattern-based approach to avoid circular import
in source code (taskmgr → app → http_controller → migration → taskmgr).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(unit): add config loader unit tests

U-005: Config Loader tests
- Valid YAML config loading
- Valid JSON config loading
- Invalid YAML/JSON error behavior
- Missing config file creation from template
- Template completion for missing keys
- ConfigManager load/dump operations
- Exists check for both YAML and JSON

All tests use tmp_path fixture, no real project config.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(unit): add chat and command handler pattern tests

U-002: Chat Handler tests (pattern-based)
- Normal message event emission pattern
- prevent_default handling
- User message alteration pattern
- Runner selection pattern
- Streaming/non-streaming response patterns
- Exception handling modes (show-error, show-hint, hide)
- Message history update pattern
- Telemetry payload pattern

U-003: Command Handler tests (pattern-based)
- Command parsing and text extraction
- Event creation pattern
- Privilege/admin check pattern
- Command result handling (text, error, image)
- prevent_default handling
- String truncation helper

Uses pattern-based testing to avoid circular import issues in source code.
Direct imports of handler modules trigger circular import chain.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* style: fix unused imports after ruff auto-fix

Remove unused imports in test files:
- test_config_loader.py: remove unused os
- test_taskmgr.py: remove unused Mock
- test_preproc.py: remove unused unsupported_query, image_chain

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(unit): improve taskmgr tests to test real classes

U-004 improved: Tests now import and test actual classes:
- TaskContext: new(), trace(), to_dict(), placeholder()
- TaskWrapper: task creation, context, exception/result capture, cancel, to_dict
- AsyncTaskManager: create_task, create_user_task, cancel_task, cancel_by_scope
- Task pruning behavior

Uses pre-mocking technique:
- Mock langbot.pkg.core.app before import (breaks circular chain)
- Mock langbot.pkg.core.entities with proper Enum

All 24 tests now test real class behavior, not patterns.
taskmgr.py coverage should improve significantly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(test): consolidate FakeApp and add sys.modules isolation utility

- Extract tests/utils/import_isolation.py with isolated_sys_modules context manager
- Extend tests/factories/app.py FakeApp with handler-specific attributes
- Refactor test_chat_handler.py to use centralized FakeApp and cached imports
- Refactor test_command_handler.py with mock_execute_factory fixture
- Refactor test_smoke.py to move import-time sys.modules manipulation into fixture
- Add SQLite migration integration tests (G-002)
- Add HTTP API smoke integration tests (G-005)
- Update CI workflow to call pytest for SQLite migrations (G-004)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(test): add developer quality gate consolidation (G-007)

- Add scripts/test-integration-fast.sh for fast integration tests
- Add scripts/test-coverage.sh with 12% baseline threshold
- Update Makefile with test-integration-fast, test-coverage, test-all-local
- Update CI workflow with integration and coverage jobs
- Add smoke marker to pytest.ini
- Update tests/README.md with quality gate layers documentation
- Add tests/integration/pipeline/ for pipeline stage-chain tests

Quality gate layers:
- Quick: ruff + unit + smoke (~2 min)
- Fast Integration: SQLite/API/Pipeline (~3 min)
- Coverage: 12% threshold gate (~8 min)
- Full Local: all three combined

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(test): add PostgreSQL migration slow integration tests (G-003)

- Add tests/integration/persistence/test_migrations_postgres.py
- All tests marked with @pytest.mark.slow
- Tests skip when TEST_POSTGRES_URL is not set (no local PostgreSQL)
- Database isolation via clean_tables and clean_alembic_version fixtures
- Update CI workflow to use pytest instead of inline Python script
- Remove TODO(G-003) comment
- Update tests/README.md with PostgreSQL test documentation

Covered scenarios:
- Baseline stamp sets revision
- Upgrade from baseline to head
- Upgrade idempotent
- Get current on unstamped DB returns None

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(test): Phase 1.5 coverage expansion - COV-001 to COV-013

Coverage baseline raised from 13.65% to 26% (+12.35%)
Gate raised from 12% to 18%

Tasks completed:
- COV-001: Command system unit tests (100% coverage)
- COV-002: API service unit tests batch 1 (user/apikey/model/provider)
- COV-003: Provider model manager unit tests
- COV-004: Pipeline remaining stage tests (aggregator/cntfilter/longtext/msgtrun)
- COV-005: Storage and utils coverage pass
- COV-006: Gate ratchet 12%→15%
- COV-007: Gate ratchet 15%→18%
- COV-008: API service batch 2 (bot/pipeline/webhook/space/maintenance/mcp)
- COV-009: Blocked - API controller circular import issue documented
- COV-010: Plugin runtime unit tests (+0.08%)
- COV-011: RAG and vector unit tests (+0.68%)
- COV-012: Core boot and migration unit tests
- COV-013: Provider requester logic unit tests (+0.62%)

Key additions:
- tests/utils/import_isolation.py: sys.modules isolation for circular imports
- Provider requester mock tests: proved HTTP-dependent code can be tested locally
- Vector filter utilities: 100% coverage on pure functions
- API services: fake persistence pattern for unit testing

Blocked issue COV-009 documented in langbot-test-plan/1.5/issues/

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(phase1): add unit tests for telemetry, plugin, rag, persistence

Add initial unit tests for Phase 1 of test coverage improvement:
- telemetry: test initialization, payload sanitization, early returns (14.3% → 62.9%)
- plugin: test _parse_plugin_id static method
- rag: test _to_i18n_name static method
- persistence: test serialize_model with datetime handling

Overall core coverage: 41.9% → 42.2%

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(phase2): add unit tests for core, persistence, plugin, utils

- Add test_handler_helpers.py for plugin handler helpers (7 tests)
- Add test_mgr_methods.py for persistence manager (5 tests)
- Add test_app_config_validation.py for core app config (12 tests)
- Add test_knowledge_service.py for API knowledge service (22 tests)
- Add test_kbmgr.py for RAG knowledge base manager (39 tests)
- Add test_survey_manager.py for survey manager (22 tests)
- Add test_connector_methods.py for plugin connector (24 tests)
- Add test_funcschema.py for utils function schema (9 tests)
- Add test_platform.py for utils platform detection (7 tests)
- Add test_extract_deps.py for plugin deps extraction (7 tests)
- Add test_database_decorator.py for persistence decorator (7 tests)
- Add test_load_config.py for core config loading (19 tests)
- Add COVERAGE_EXCLUSIONS.md documenting external adapter exclusions
- Fix test_chat_session_limit.py path for portability

Coverage: core 28% → 30%, persistence 24% → 24.4%, plugin 27% → 28%
Total: 1082 tests passed, core module coverage 45.5%

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(integration): add API controller integration tests

- Add test_pipelines.py (10 tests) covering pipelines CRUD operations
  - GET/POST/PUT/DELETE on /api/v1/pipelines
  - Extensions endpoint
  - Metadata endpoint
  - Coverage: pipelines controller 27% → 80%

- Add test_providers.py (10 tests) covering provider/model management
  - Provider CRUD with model counts
  - LLM model CRUD
  - Coverage: providers controller 23% → 81%, models 29% → 45%

Tests use Quart TestClient with mocked services for real HTTP behavior
without external dependencies.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(integration): add knowledge, bots, and model endpoints tests

- Add test_knowledge.py (10 tests) covering knowledge base management
  - CRUD operations on /api/v1/knowledge/bases
  - Files management endpoints
  - Retrieve endpoint with validation
  - Coverage: knowledge/base.py 26% → 91%

- Add test_bots.py (9 tests) covering bot management
  - CRUD operations on /api/v1/platform/bots
  - Logs endpoint
  - Send message endpoint with validation
  - Coverage: platform/bots.py 24% → 87%

- Extend test_providers.py (+4 tests) for embedding/rerank models
  - Embedding models CRUD
  - Rerank models CRUD
  - Coverage: provider/models.py 29% → 60%

Total integration tests: 53 (smoke 12 + pipelines 10 + providers 14 + knowledge 10 + bots 9)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(integration): add embed and monitoring endpoint tests

Add integration tests for embed widget and monitoring API endpoints:
- test_embed.py: 15 tests for widget.js, logo, turnstile, messages, reset, feedback
- test_monitoring.py: 15 tests for overview, messages, llm-calls, sessions, errors, export

Coverage improvements:
- embed.py: 17% → 56%
- monitoring.py: 17% → 93%

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(e2e): add minimal startup E2E tests

Add E2E tests for LangBot startup flow:
- tests/e2e/utils/config_factory.py: minimal config generation
- tests/e2e/utils/process_manager.py: LangBot subprocess management
- tests/e2e/conftest.py: E2E fixtures (session-scoped process)
- tests/e2e/test_startup.py: 12 tests for startup verification

Tests verify:
- boot.py + stages execution
- database initialization (SQLite)
- API availability
- migrations applied

Uses embedded databases (SQLite, Chroma) - no external dependencies.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(quality): fix fake tests and add missing coverage

P0 fixes:
- telemetry: rewrite fake tests with real behavior verification (25 tests)
- config: delete copied-source tests, use proper imports (2 deleted)
- persistence: fix try-except pass to verify specific errors

P1 fixes:
- pipeline: add real FixedWindowAlgo tests instead of mocks (12 tests)
- provider: add SessionManager and ToolManager tests (25 tests)
- storage: add S3StorageProvider tests with moto mock (16 tests)
- plugin: add handler action tests for setting inheritance (15 tests)
- rag: add file storage and ZIP processing tests (21 tests)
- vector: add VDB filter conversion tests (30 tests)

P2 fixes:
- pipeline/msgtrun: strengthen assertions for exact message count
- api: add response structure validation in integration tests

New test files:
- provider/test_session_manager.py
- provider/test_tool_manager.py
- storage/test_s3storage.py
- plugin/test_handler_actions.py
- rag/test_file_storage.py
- vector/test_vdb_filter_conversion.py

Source code bugs documented:
- provider: TokenManager.next_token() ZeroDivisionError
- telemetry: send_tasks class variable shared state
- command: empty command IndexError, unused parameters
- utils: funcschema KeyError
- entity: vector.py independent declarative_base

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(test): update coverage stats and test structure

- Update coverage from 22% to 30%
- Add new test files to structure:
  - provider: session_manager, tool_manager
  - storage: s3storage
  - plugin: handler_actions
  - rag: file_storage
  - vector: vdb_filter_conversion
  - telemetry: rewritten tests
- Update module coverage percentages

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test: add 105 new unit tests for untested core functionality

Add comprehensive tests for B-class issues (core functionality untested):

Pipeline:
- test_pool.py: QueryPool ID generation, caching, async context (12 tests)
- test_ratelimit.py: Fixed timing-sensitive test tolerance
- test_pipelinemgr.py: Use real Pydantic StageProcessResult instead of Mock

Utils:
- test_version.py: Version comparison functions (20 tests)
- test_logcache.py: Log page management and retrieval (18 tests)
- test_httpclient.py: HTTP session pool management (10 tests)
- test_proxy.py: Proxy configuration from env and config (10 tests)
- test_image.py: URL parsing and base64 extraction (12 tests)
- test_pkgmgr.py: Pip command generation (8 tests)

Discover:
- test_engine.py: I18nString, Metadata, Component manifest (15 tests)

Test count: 1193 → 1298 (+105 tests)

Note: Some B-class issues cannot be tested due to circular import bugs
filed as GitHub issues #2175 (pipeline) and #2176 (persistence).

* test: tighten phase 1 coverage contracts

* test: align ci integration isolation

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
huanghuoguoguo
2026-05-16 12:05:54 +08:00
committed by GitHub
parent 4a4c0921a4
commit 17bbc8bf10
130 changed files with 32711 additions and 889 deletions
+1
View File
@@ -0,0 +1 @@
"""Core module unit tests."""
@@ -0,0 +1,191 @@
"""Unit tests for core app config validation methods.
Tests cover:
- _get_positive_int_config() validation
- _get_positive_float_config() validation
"""
from __future__ import annotations
from unittest.mock import Mock
from importlib import import_module
def get_app_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.core.app')
class TestGetPositiveIntConfig:
"""Tests for _get_positive_int_config method."""
def test_returns_value_when_valid_positive_int(self):
"""Test returns parsed int for valid positive value."""
app_module = get_app_module()
mock_logger = Mock()
app = app_module.Application()
app.logger = mock_logger
result = app._get_positive_int_config(10, default=30, name='test.config')
assert result == 10
mock_logger.warning.assert_not_called()
def test_returns_value_when_valid_string_int(self):
"""Test returns parsed int for string value."""
app_module = get_app_module()
mock_logger = Mock()
app = app_module.Application()
app.logger = mock_logger
result = app._get_positive_int_config('50', default=30, name='test.config')
assert result == 50
mock_logger.warning.assert_not_called()
def test_returns_default_for_zero(self):
"""Test returns default when value is zero."""
app_module = get_app_module()
mock_logger = Mock()
app = app_module.Application()
app.logger = mock_logger
result = app._get_positive_int_config(0, default=30, name='test.config')
assert result == 30
mock_logger.warning.assert_called_once()
def test_returns_default_for_negative(self):
"""Test returns default when value is negative."""
app_module = get_app_module()
mock_logger = Mock()
app = app_module.Application()
app.logger = mock_logger
result = app._get_positive_int_config(-5, default=30, name='test.config')
assert result == 30
mock_logger.warning.assert_called_once()
def test_returns_default_for_invalid_string(self):
"""Test returns default when value is invalid string."""
app_module = get_app_module()
mock_logger = Mock()
app = app_module.Application()
app.logger = mock_logger
result = app._get_positive_int_config('invalid', default=30, name='test.config')
assert result == 30
mock_logger.warning.assert_called_once()
def test_returns_default_for_none(self):
"""Test returns default when value is None."""
app_module = get_app_module()
mock_logger = Mock()
app = app_module.Application()
app.logger = mock_logger
result = app._get_positive_int_config(None, default=30, name='test.config')
assert result == 30
mock_logger.warning.assert_called_once()
class TestGetPositiveFloatConfig:
"""Tests for _get_positive_float_config method."""
def test_returns_value_when_valid_positive_float(self):
"""Test returns parsed float for valid positive value."""
app_module = get_app_module()
mock_logger = Mock()
app = app_module.Application()
app.logger = mock_logger
result = app._get_positive_float_config(1.5, default=2.0, name='test.config')
assert result == 1.5
mock_logger.warning.assert_not_called()
def test_returns_value_when_valid_int(self):
"""Test returns float for valid int value."""
app_module = get_app_module()
mock_logger = Mock()
app = app_module.Application()
app.logger = mock_logger
result = app._get_positive_float_config(2, default=1.0, name='test.config')
assert result == 2.0
mock_logger.warning.assert_not_called()
def test_returns_value_when_valid_string_float(self):
"""Test returns parsed float for string value."""
app_module = get_app_module()
mock_logger = Mock()
app = app_module.Application()
app.logger = mock_logger
result = app._get_positive_float_config('0.5', default=1.0, name='test.config')
assert result == 0.5
mock_logger.warning.assert_not_called()
def test_returns_default_for_zero(self):
"""Test returns default when value is zero."""
app_module = get_app_module()
mock_logger = Mock()
app = app_module.Application()
app.logger = mock_logger
result = app._get_positive_float_config(0.0, default=1.0, name='test.config')
assert result == 1.0
mock_logger.warning.assert_called_once()
def test_returns_default_for_negative(self):
"""Test returns default when value is negative."""
app_module = get_app_module()
mock_logger = Mock()
app = app_module.Application()
app.logger = mock_logger
result = app._get_positive_float_config(-1.0, default=2.0, name='test.config')
assert result == 2.0
mock_logger.warning.assert_called_once()
def test_returns_default_for_invalid_string(self):
"""Test returns default when value is invalid string."""
app_module = get_app_module()
mock_logger = Mock()
app = app_module.Application()
app.logger = mock_logger
result = app._get_positive_float_config('not-a-number', default=1.5, name='test.config')
assert result == 1.5
mock_logger.warning.assert_called_once()
@@ -0,0 +1,134 @@
"""Tests for core bootutils dependency checking."""
from __future__ import annotations
import importlib.util
from unittest.mock import MagicMock, patch
from tests.utils.import_isolation import isolated_sys_modules
class TestCheckDeps:
"""Tests for check_deps function."""
def _make_deps_import_mocks(self):
"""Create mocks for deps import."""
return {
'langbot.pkg.utils.pkgmgr': MagicMock(),
}
def test_check_deps_all_present(self):
"""check_deps returns empty list when all deps present."""
mocks = self._make_deps_import_mocks()
with isolated_sys_modules(mocks):
# Mock find_spec to always return a spec (module found)
with patch.object(importlib.util, 'find_spec', return_value=MagicMock()):
from langbot.pkg.core.bootutils.deps import check_deps
import asyncio
result = asyncio.get_event_loop().run_until_complete(check_deps())
assert result == []
def test_check_deps_missing_deps(self):
"""check_deps returns list of missing deps."""
mocks = self._make_deps_import_mocks()
with isolated_sys_modules(mocks):
# Mock find_spec to return None for some deps
def mock_find_spec(name):
if name in ['requests', 'openai']:
return None # Missing
return MagicMock() # Present
with patch.object(importlib.util, 'find_spec', side_effect=mock_find_spec):
from langbot.pkg.core.bootutils.deps import check_deps
import asyncio
result = asyncio.get_event_loop().run_until_complete(check_deps())
assert 'requests' in result
assert 'openai' in result
def test_check_deps_all_missing(self):
"""check_deps returns all deps when none present."""
mocks = self._make_deps_import_mocks()
with isolated_sys_modules(mocks):
# Mock find_spec to always return None
with patch.object(importlib.util, 'find_spec', return_value=None):
from langbot.pkg.core.bootutils.deps import check_deps, required_deps
import asyncio
result = asyncio.get_event_loop().run_until_complete(check_deps())
# Should include all required_deps keys
assert len(result) == len(required_deps)
def test_required_deps_dict_exists(self):
"""required_deps dictionary is defined."""
mocks = self._make_deps_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.bootutils.deps import required_deps
assert isinstance(required_deps, dict)
assert len(required_deps) > 0
# Check some expected deps
assert 'requests' in required_deps
assert 'yaml' in required_deps
def test_required_deps_maps_import_name_to_package_name(self):
"""required_deps maps import name to package name."""
mocks = self._make_deps_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.bootutils.deps import required_deps
# Some import names differ from package names
assert required_deps['PIL'] == 'pillow'
assert required_deps['yaml'] == 'pyyaml'
assert required_deps['jwt'] == 'pyjwt'
class TestPrecheckPluginDeps:
"""Tests for precheck_plugin_deps function."""
def _make_deps_import_mocks(self):
return {
'langbot.pkg.utils.pkgmgr': MagicMock(),
}
def test_precheck_plugin_deps_no_plugins_dir(self):
"""precheck_plugin_deps skips when plugins dir doesn't exist."""
from langbot.pkg.core.bootutils.deps import precheck_plugin_deps
with patch('os.path.exists', return_value=False):
with patch('langbot.pkg.core.bootutils.deps.pkgmgr.install_requirements') as mock_install:
import asyncio
asyncio.get_event_loop().run_until_complete(precheck_plugin_deps())
mock_install.assert_not_called()
def test_precheck_plugin_deps_with_plugins_dir(self):
"""precheck_plugin_deps checks plugins subdirectories."""
from langbot.pkg.core.bootutils.deps import precheck_plugin_deps
def mock_listdir(path):
if path == 'plugins':
return ['plugin1', 'plugin2']
if path == 'plugins/plugin1':
return ['requirements.txt', 'main.py']
if path == 'plugins/plugin2':
return ['main.py']
return []
with patch('os.path.exists', return_value=True):
with patch('os.path.isdir', return_value=True):
with patch('os.listdir', side_effect=mock_listdir):
with patch('langbot.pkg.core.bootutils.deps.pkgmgr.install_requirements') as mock_install:
import asyncio
asyncio.get_event_loop().run_until_complete(precheck_plugin_deps())
mock_install.assert_called_once_with('plugins/plugin1/requirements.txt', extra_params=[])
+290
View File
@@ -0,0 +1,290 @@
"""Unit tests for core stages load_config _apply_env_overrides_to_config.
Tests cover:
- Environment variable parsing and path conversion
- Type conversion (bool, int, float, string)
- List handling (comma-separated)
- Dict type skipping
- Missing key creation
"""
from __future__ import annotations
import os
from unittest.mock import patch
from importlib import import_module
def get_load_config_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.core.stages.load_config')
class TestApplyEnvOverridesToConfig:
"""Tests for _apply_env_overrides_to_config function."""
def test_override_string_value(self):
"""Test overriding an existing string config value."""
load_config = get_load_config_module()
cfg = {'system': {'name': 'default'}}
env = {'SYSTEM__NAME': 'custom_name'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['system']['name'] == 'custom_name'
def test_override_int_value(self):
"""Test overriding an int value with proper conversion."""
load_config = get_load_config_module()
cfg = {'concurrency': {'pipeline': 5}}
env = {'CONCURRENCY__PIPELINE': '10'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['concurrency']['pipeline'] == 10
assert isinstance(result['concurrency']['pipeline'], int)
def test_override_int_value_invalid_conversion(self):
"""Test that invalid int conversion keeps string value."""
load_config = get_load_config_module()
cfg = {'concurrency': {'pipeline': 5}}
env = {'CONCURRENCY__PIPELINE': 'not_a_number'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
# Falls back to string when conversion fails
assert result['concurrency']['pipeline'] == 'not_a_number'
def test_override_bool_value_true(self):
"""Test overriding bool value with 'true' string."""
load_config = get_load_config_module()
cfg = {'system': {'enable': False}}
env = {'SYSTEM__ENABLE': 'true'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['system']['enable'] is True
def test_override_bool_value_false(self):
"""Test overriding bool value with 'false' string."""
load_config = get_load_config_module()
cfg = {'system': {'enable': True}}
env = {'SYSTEM__ENABLE': 'false'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['system']['enable'] is False
def test_override_bool_value_various_true_forms(self):
"""Test that '1', 'yes', 'on' are treated as true."""
load_config = get_load_config_module()
cfg = {'system': {'flag': False}}
for true_val in ['1', 'yes', 'on', 'TRUE']:
env = {'SYSTEM__FLAG': true_val}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg.copy())
assert result['system']['flag'] is True
def test_override_float_value(self):
"""Test overriding float value with proper conversion."""
load_config = get_load_config_module()
cfg = {'system': {'timeout': 1.5}}
env = {'SYSTEM__TIMEOUT': '2.5'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['system']['timeout'] == 2.5
assert isinstance(result['system']['timeout'], float)
def test_override_list_value(self):
"""Test that comma-separated string converts to list."""
load_config = get_load_config_module()
cfg = {'system': {'disabled_adapters': ['adapter1']}}
env = {'SYSTEM__DISABLED_ADAPTERS': 'aiocqhttp,dingtalk,telegram'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['system']['disabled_adapters'] == ['aiocqhttp', 'dingtalk', 'telegram']
def test_override_list_value_empty_items(self):
"""Test that empty items in comma-separated list are filtered."""
load_config = get_load_config_module()
cfg = {'system': {'disabled_adapters': []}}
env = {'SYSTEM__DISABLED_ADAPTERS': 'a,,b,,,c'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
# Empty items should be filtered out
assert result['system']['disabled_adapters'] == ['a', 'b', 'c']
def test_skip_dict_type_override(self):
"""Test that dict type values are skipped."""
load_config = get_load_config_module()
cfg = {'plugin': {'settings': {'nested': 'value'}}}
env = {'PLUGIN__SETTINGS': 'should_not_apply'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
# Dict type should not be overridden
assert result['plugin']['settings'] == {'nested': 'value'}
def test_create_new_key_when_missing(self):
"""Test that missing keys are created as strings."""
load_config = get_load_config_module()
cfg = {'system': {}}
env = {'SYSTEM__NEW_KEY': 'new_value'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['system']['new_key'] == 'new_value'
def test_create_nested_path(self):
"""Test that intermediate dict is created for nested path."""
load_config = get_load_config_module()
cfg = {}
env = {'NEW__SECTION__KEY': 'value'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['new']['section']['key'] == 'value'
def test_skip_non_uppercase_env_vars(self):
"""Test that non-uppercase env vars are skipped."""
load_config = get_load_config_module()
cfg = {'system': {'name': 'default'}}
env = {'system__name': 'should_not_apply'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['system']['name'] == 'default'
def test_skip_env_vars_without_double_underscore(self):
"""Test that env vars without __ are skipped."""
load_config = get_load_config_module()
cfg = {'system': {'name': 'default'}}
env = {'SYSTEMNAME': 'should_not_apply'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['system']['name'] == 'default'
def test_nested_config_path(self):
"""Test overriding deeply nested config."""
load_config = get_load_config_module()
cfg = {'level1': {'level2': {'level3': 'original'}}}
env = {'LEVEL1__LEVEL2__LEVEL3': 'overridden'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['level1']['level2']['level3'] == 'overridden'
def test_non_dict_current_breaks(self):
"""Test that path navigation stops when current is not dict."""
load_config = get_load_config_module()
cfg = {'system': 'not_a_dict'}
env = {'SYSTEM__NAME': 'should_not_apply'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
# Should remain unchanged since 'system' is not a dict
assert result == {'system': 'not_a_dict'}
def test_empty_config(self):
"""Test that empty config dict is handled."""
load_config = get_load_config_module()
cfg = {}
env = {'SOME__KEY': 'value'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['some']['key'] == 'value'
def test_no_matching_env_vars(self):
"""Test that config is unchanged when no matching env vars."""
load_config = get_load_config_module()
cfg = {'system': {'name': 'default'}}
env = {'OTHER_VAR': 'value'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result == cfg
def test_multiple_env_vars_override(self):
"""Test multiple env vars applied in order."""
load_config = get_load_config_module()
cfg = {
'system': {'name': 'default', 'enable': True},
'concurrency': {'pipeline': 5}
}
env = {
'SYSTEM__NAME': 'custom',
'SYSTEM__ENABLE': 'false',
'CONCURRENCY__PIPELINE': '10'
}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['system']['name'] == 'custom'
assert result['system']['enable'] is False
assert result['concurrency']['pipeline'] == 10
def test_webhook_prefix_override(self):
"""Test overriding webhook_prefix via environment variable."""
load_config = get_load_config_module()
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
env = {'API__WEBHOOK_PREFIX': 'https://example.com:8080'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['api']['webhook_prefix'] == 'https://example.com:8080'
def test_extra_webhook_prefix_override(self):
"""Test overriding extra_webhook_prefix via environment variable."""
load_config = get_load_config_module()
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
env = {'API__EXTRA_WEBHOOK_PREFIX': 'https://extra.example.com'}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['api']['extra_webhook_prefix'] == 'https://extra.example.com'
+238
View File
@@ -0,0 +1,238 @@
"""Tests for core migration registration and abstract classes."""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from tests.utils.import_isolation import isolated_sys_modules
class TestMigrationClassDecorator:
"""Tests for @migration_class decorator."""
def _make_migration_import_mocks(self):
"""Create mocks for migration import."""
return {
'langbot.pkg.core.app': MagicMock(),
}
def test_migration_class_registers_migration(self):
"""@migration_class registers migration in preregistered_migrations."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import migration_class, preregistered_migrations
# Clear for clean test
preregistered_migrations.clear()
@migration_class('test-migration', 1)
class TestMigration:
pass
assert len(preregistered_migrations) == 1
assert preregistered_migrations[0] == TestMigration
def test_migration_class_sets_name_attribute(self):
"""@migration_class sets name attribute on class."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import migration_class
@migration_class('test-migration', 1)
class TestMigration:
pass
assert TestMigration.name == 'test-migration'
def test_migration_class_sets_number_attribute(self):
"""@migration_class sets number attribute on class."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import migration_class
@migration_class('test-migration', 42)
class TestMigration:
pass
assert TestMigration.number == 42
def test_migration_class_returns_original_class(self):
"""@migration_class returns the original class."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import migration_class
@migration_class('test', 1)
class TestMigration:
custom_attr = 'value'
assert TestMigration.custom_attr == 'value'
def test_migration_class_multiple_migrations(self):
"""Multiple migrations can be registered."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import migration_class, preregistered_migrations
preregistered_migrations.clear()
@migration_class('migration1', 1)
class Migration1:
pass
@migration_class('migration2', 2)
class Migration2:
pass
assert len(preregistered_migrations) == 2
assert preregistered_migrations[0] == Migration1
assert preregistered_migrations[1] == Migration2
class TestMigrationAbstractClass:
"""Tests for Migration abstract class."""
def _make_migration_import_mocks(self):
return {'langbot.pkg.core.app': MagicMock()}
def test_migration_is_abstract(self):
"""Migration is abstract and cannot be instantiated directly."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import Migration
with pytest.raises(TypeError):
Migration(MagicMock())
def test_migration_requires_need_migrate_method(self):
"""Subclass must implement need_migrate method."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import Migration
class IncompleteMigration(Migration):
async def run(self):
pass
with pytest.raises(TypeError):
IncompleteMigration(MagicMock())
def test_migration_requires_run_method(self):
"""Subclass must implement run method."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import Migration
class IncompleteMigration(Migration):
async def need_migrate(self) -> bool:
return False
with pytest.raises(TypeError):
IncompleteMigration(MagicMock())
def test_migration_subclass_works(self):
"""Complete subclass can be instantiated."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import Migration
class CompleteMigration(Migration):
async def need_migrate(self) -> bool:
return True
async def run(self):
pass
mock_ap = MagicMock()
migration = CompleteMigration(mock_ap)
assert migration.ap == mock_ap
def test_migration_stores_app_reference(self):
"""Migration stores ap reference in __init__."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import Migration
class TestMigration(Migration):
async def need_migrate(self) -> bool:
return False
async def run(self):
pass
mock_ap = MagicMock()
migration = TestMigration(mock_ap)
assert migration.ap is mock_ap
@pytest.mark.asyncio
async def test_migration_need_migrate_returns_bool(self):
"""need_migrate must return bool."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import Migration
class TestMigration(Migration):
async def need_migrate(self) -> bool:
return True
async def run(self):
pass
migration = TestMigration(MagicMock())
result = await migration.need_migrate()
assert isinstance(result, bool)
assert result == True
class TestPreregisteredMigrations:
"""Tests for preregistered_migrations global registry."""
def _make_migration_import_mocks(self):
return {'langbot.pkg.core.app': MagicMock()}
def test_preregistered_migrations_is_list(self):
"""preregistered_migrations is a list."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import preregistered_migrations
assert isinstance(preregistered_migrations, list)
def test_preregistered_migrations_order(self):
"""Migrations are registered in order of decoration."""
mocks = self._make_migration_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.migration import migration_class, preregistered_migrations
preregistered_migrations.clear()
@migration_class('first', 1)
class First:
pass
@migration_class('second', 2)
class Second:
pass
@migration_class('third', 3)
class Third:
pass
# Order should match decoration order
assert preregistered_migrations[0].number == 1
assert preregistered_migrations[1].number == 2
assert preregistered_migrations[2].number == 3
+178
View File
@@ -0,0 +1,178 @@
"""Tests for core boot stage registration and abstract classes."""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from tests.utils.import_isolation import isolated_sys_modules
class TestStageClassDecorator:
"""Tests for @stage_class decorator."""
def _make_stage_import_mocks(self):
"""Create mocks for stage import."""
return {
'langbot.pkg.core.app': MagicMock(),
}
def test_stage_class_registers_stage(self):
"""@stage_class registers stage in preregistered_stages."""
mocks = self._make_stage_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import stage_class, preregistered_stages
# Clear for clean test
preregistered_stages.clear()
@stage_class('TestStage')
class TestStage:
pass
assert 'TestStage' in preregistered_stages
assert preregistered_stages['TestStage'] == TestStage
def test_stage_class_returns_original_class(self):
"""@stage_class returns the original class unchanged."""
mocks = self._make_stage_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import stage_class
@stage_class('TestStage')
class TestStage:
value = 42
# Class attributes should be preserved
assert TestStage.value == 42
def test_stage_class_multiple_stages(self):
"""Multiple stages can be registered."""
mocks = self._make_stage_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import stage_class, preregistered_stages
preregistered_stages.clear()
@stage_class('Stage1')
class Stage1:
pass
@stage_class('Stage2')
class Stage2:
pass
assert len(preregistered_stages) == 2
assert preregistered_stages['Stage1'] == Stage1
assert preregistered_stages['Stage2'] == Stage2
class TestBootingStageAbstract:
"""Tests for BootingStage abstract class."""
def _make_stage_import_mocks(self):
return {'langbot.pkg.core.app': MagicMock()}
def test_booting_stage_is_abstract(self):
"""BootingStage is abstract and cannot be instantiated directly."""
mocks = self._make_stage_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import BootingStage
with pytest.raises(TypeError):
BootingStage()
def test_booting_stage_requires_run_method(self):
"""Subclass must implement run method."""
mocks = self._make_stage_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import BootingStage
class IncompleteStage(BootingStage):
pass
with pytest.raises(TypeError):
IncompleteStage()
def test_booting_stage_subclass_works(self):
"""Complete subclass can be instantiated."""
mocks = self._make_stage_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import BootingStage
class CompleteStage(BootingStage):
name = 'CompleteStage'
async def run(self, ap):
pass
stage = CompleteStage()
assert stage.name == 'CompleteStage'
def test_booting_stage_name_attribute(self):
"""BootingStage has name attribute (None by default in abstract)."""
mocks = self._make_stage_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import BootingStage
# Abstract class has name attribute defined as None
assert hasattr(BootingStage, 'name')
@pytest.mark.asyncio
async def test_booting_stage_run_signature(self):
"""run method receives Application parameter."""
mocks = self._make_stage_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import BootingStage
class TestStage(BootingStage):
name = 'TestStage'
async def run(self, ap):
self.ap_received = ap
stage = TestStage()
mock_ap = MagicMock()
await stage.run(mock_ap)
assert stage.ap_received == mock_ap
class TestPreregisteredStages:
"""Tests for preregistered_stages global registry."""
def _make_stage_import_mocks(self):
return {'langbot.pkg.core.app': MagicMock()}
def test_preregistered_stages_is_dict(self):
"""preregistered_stages is a dictionary."""
mocks = self._make_stage_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import preregistered_stages
assert isinstance(preregistered_stages, dict)
def test_preregistered_stages_key_is_string(self):
"""Registry keys are stage names (strings)."""
mocks = self._make_stage_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import stage_class, preregistered_stages
preregistered_stages.clear()
@stage_class('MyStage')
class MyStage:
pass
for key in preregistered_stages:
assert isinstance(key, str)
+506
View File
@@ -0,0 +1,506 @@
"""Unit tests for core TaskContext, TaskWrapper, and AsyncTaskManager.
Tests cover:
- TaskContext initialization, state tracking, serialization
- TaskWrapper ID generation, to_dict serialization
- AsyncTaskManager task creation, stats, pruning
Note: Uses import_isolation to break circular import chains.
"""
from __future__ import annotations
import pytest
import asyncio
import sys
from unittest.mock import Mock, MagicMock
from contextlib import contextmanager
from typing import Generator
class MockLifecycleControlScopeEnum:
"""Mock enum value for LifecycleControlScope with .value attribute."""
def __init__(self, value: str):
self.value = value
def __repr__(self):
return f"LifecycleControlScope.{self.value.upper()}"
class MockLifecycleControlScope:
"""Mock enum for LifecycleControlScope."""
APPLICATION = MockLifecycleControlScopeEnum('application')
PLATFORM = MockLifecycleControlScopeEnum('platform')
PIPELINE = MockLifecycleControlScopeEnum('pipeline')
PLUGIN = MockLifecycleControlScopeEnum('plugin')
@contextmanager
def isolated_taskmgr_import() -> Generator[None, None, None]:
"""Context manager to isolate circular imports for taskmgr testing."""
# Mock modules that cause circular imports
mock_entities = MagicMock()
mock_entities.LifecycleControlScope = MockLifecycleControlScope
mock_app = MagicMock()
mock_importutil = MagicMock()
mock_importutil.import_modules_in_pkg = lambda pkg: None
mock_importutil.import_modules_in_pkgs = lambda pkgs: None
mock_http_controller = MagicMock()
mock_rag_mgr = MagicMock()
mocks = {
'langbot.pkg.core.entities': mock_entities,
'langbot.pkg.core.app': mock_app,
'langbot.pkg.api.http.controller.main': mock_http_controller,
'langbot.pkg.rag.knowledge.kbmgr': mock_rag_mgr,
'langbot.pkg.utils.importutil': mock_importutil,
}
# Save original state
saved = {}
for name in mocks:
if name in sys.modules:
saved[name] = sys.modules[name]
# Clear taskmgr to force re-import
taskmgr_name = 'langbot.pkg.core.taskmgr'
if taskmgr_name in sys.modules:
saved[taskmgr_name] = sys.modules[taskmgr_name]
try:
# Apply mocks
for name, module in mocks.items():
sys.modules[name] = module
# Clear taskmgr
sys.modules.pop(taskmgr_name, None)
yield
finally:
# Restore
for name in mocks:
if name in saved:
sys.modules[name] = saved[name]
else:
sys.modules.pop(name, None)
if taskmgr_name in saved:
sys.modules[taskmgr_name] = saved[taskmgr_name]
else:
sys.modules.pop(taskmgr_name, None)
def get_taskmgr_classes():
"""Get TaskContext, TaskWrapper, AsyncTaskManager classes."""
with isolated_taskmgr_import():
from langbot.pkg.core.taskmgr import TaskContext, TaskWrapper, AsyncTaskManager
return TaskContext, TaskWrapper, AsyncTaskManager
def create_mock_app():
"""Create a mock Application for testing."""
mock_app = Mock()
mock_app.event_loop = asyncio.get_running_loop()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {
'system': {
'task_retention': {
'completed_limit': 200,
}
}
}
return mock_app
class TestTaskContext:
"""Tests for TaskContext class."""
def test_init_default_values(self):
"""Test that TaskContext initializes with default values."""
TaskContext, _, _ = get_taskmgr_classes()
ctx = TaskContext()
assert ctx.current_action == 'default'
assert ctx.log == ''
assert ctx.metadata == {}
def test_set_current_action(self):
"""Test setting current action."""
TaskContext, _, _ = get_taskmgr_classes()
ctx = TaskContext()
ctx.set_current_action('installing_plugin')
assert ctx.current_action == 'installing_plugin'
def test_trace_without_action(self):
"""Test trace method without action override."""
TaskContext, _, _ = get_taskmgr_classes()
ctx = TaskContext()
ctx.trace('Starting process')
assert 'Starting process' in ctx.log
assert ctx.current_action == 'default'
def test_trace_with_action_override(self):
"""Test trace method with action override."""
TaskContext, _, _ = get_taskmgr_classes()
ctx = TaskContext()
ctx.trace('Downloading', action='download')
assert 'Downloading' in ctx.log
assert ctx.current_action == 'download'
def test_trace_accumulates_logs(self):
"""Test that trace accumulates log entries."""
TaskContext, _, _ = get_taskmgr_classes()
ctx = TaskContext()
ctx.trace('Step 1')
ctx.trace('Step 2')
ctx.trace('Step 3')
assert 'Step 1' in ctx.log
assert 'Step 2' in ctx.log
assert 'Step 3' in ctx.log
# Each trace adds a newline
assert ctx.log.count('\n') == 3
def test_to_dict_serialization(self):
"""Test to_dict serialization."""
TaskContext, _, _ = get_taskmgr_classes()
ctx = TaskContext()
ctx.set_current_action('test_action')
ctx.trace('Test message')
ctx.metadata['key'] = 'value'
result = ctx.to_dict()
assert result['current_action'] == 'test_action'
assert 'Test message' in result['log']
assert result['metadata'] == {'key': 'value'}
def test_static_new_factory(self):
"""Test TaskContext.new() factory method."""
TaskContext, _, _ = get_taskmgr_classes()
ctx = TaskContext.new()
assert isinstance(ctx, TaskContext)
assert ctx.current_action == 'default'
def test_static_placeholder_singleton(self):
"""Test TaskContext.placeholder() returns singleton."""
with isolated_taskmgr_import():
from langbot.pkg.core.taskmgr import TaskContext
# Reset global placeholder
import langbot.pkg.core.taskmgr as taskmgr_module
taskmgr_module.placeholder_context = None
ctx1 = TaskContext.placeholder()
ctx2 = TaskContext.placeholder()
assert ctx1 is ctx2
def test_metadata_is_mutable_dict(self):
"""Test that metadata is a mutable dict."""
TaskContext, _, _ = get_taskmgr_classes()
ctx = TaskContext()
ctx.metadata['count'] = 5
ctx.metadata['items'] = ['a', 'b', 'c']
assert ctx.metadata['count'] == 5
assert len(ctx.metadata['items']) == 3
class TestTaskWrapper:
"""Tests for TaskWrapper class."""
@pytest.mark.asyncio
async def test_id_auto_increment(self):
"""Test that task IDs auto-increment."""
TaskContext, TaskWrapper, _ = get_taskmgr_classes()
# Reset ID index
TaskWrapper._id_index = 0
mock_app = create_mock_app()
async def dummy_coro():
await asyncio.sleep(0.01)
return 'done'
wrapper1 = TaskWrapper(mock_app, dummy_coro())
wrapper2 = TaskWrapper(mock_app, dummy_coro())
assert wrapper1.id == 0
assert wrapper2.id == 1
# Clean up
wrapper1.cancel()
wrapper2.cancel()
@pytest.mark.asyncio
async def test_default_task_type_and_kind(self):
"""Test default task_type and kind values."""
_, TaskWrapper, _ = get_taskmgr_classes()
mock_app = create_mock_app()
async def dummy_coro():
return 'done'
wrapper = TaskWrapper(mock_app, dummy_coro())
assert wrapper.task_type == 'system'
assert wrapper.kind == 'system_task'
wrapper.cancel()
@pytest.mark.asyncio
async def test_to_dict_serialization(self):
"""Test TaskWrapper.to_dict serialization."""
_, TaskWrapper, _ = get_taskmgr_classes()
mock_app = create_mock_app()
async def immediate_coro():
return 'result'
wrapper = TaskWrapper(
mock_app, immediate_coro(),
name='test_task',
label='Test Task',
)
# Wait for task to complete
await wrapper.task
result = wrapper.to_dict()
assert result['name'] == 'test_task'
assert result['label'] == 'Test Task'
assert result['task_type'] == 'system'
assert result['runtime']['done'] == True
assert result['runtime']['result'] == 'result'
@pytest.mark.asyncio
async def test_to_dict_with_exception(self):
"""Test TaskWrapper.to_dict when task has exception."""
_, TaskWrapper, _ = get_taskmgr_classes()
mock_app = create_mock_app()
async def failing_coro():
raise ValueError('Test error')
wrapper = TaskWrapper(mock_app, failing_coro())
# Wait for task to complete
try:
await wrapper.task
except ValueError:
pass
result = wrapper.to_dict()
assert result['runtime']['done'] == True
assert result['runtime']['exception'] == 'Test error'
assert 'exception_traceback' in result['runtime']
@pytest.mark.asyncio
async def test_cancel_task(self):
"""Test cancel method cancels the asyncio task."""
_, TaskWrapper, _ = get_taskmgr_classes()
mock_app = create_mock_app()
async def long_coro():
await asyncio.sleep(10)
return 'done'
wrapper = TaskWrapper(mock_app, long_coro())
# Task should be running
assert not wrapper.task.done()
wrapper.cancel()
# Give it a moment to be cancelled
await asyncio.sleep(0.01)
assert wrapper.task.done()
assert wrapper.task.cancelled()
class TestAsyncTaskManager:
"""Tests for AsyncTaskManager class."""
@pytest.mark.asyncio
async def test_create_task_adds_to_list(self):
"""Test that create_task adds task to tasks list."""
_, _, AsyncTaskManager = get_taskmgr_classes()
mock_app = create_mock_app()
manager = AsyncTaskManager(mock_app)
async def dummy_coro():
await asyncio.sleep(0.01)
return 'done'
wrapper = manager.create_task(dummy_coro())
assert wrapper in manager.tasks
assert len(manager.tasks) == 1
wrapper.cancel()
@pytest.mark.asyncio
async def test_get_stats_counts_correctly(self):
"""Test get_stats returns correct counts."""
_, _, AsyncTaskManager = get_taskmgr_classes()
mock_app = create_mock_app()
manager = AsyncTaskManager(mock_app)
async def immediate_coro():
return 'done'
async def delayed_coro():
await asyncio.sleep(0.1)
return 'done'
# Create tasks
w1 = manager.create_task(immediate_coro())
w2 = manager.create_task(delayed_coro())
# Wait for first to complete
await w1.task
stats = manager.get_stats()
assert stats['total'] == 2
assert stats['completed'] == 1
assert stats['running'] == 1
w2.cancel()
@pytest.mark.asyncio
async def test_get_tasks_dict_filters_by_type(self):
"""Test get_tasks_dict filters by type."""
_, _, AsyncTaskManager = get_taskmgr_classes()
mock_app = create_mock_app()
manager = AsyncTaskManager(mock_app)
async def dummy_coro():
await asyncio.sleep(0.01)
# Create system and user tasks
w1 = manager.create_task(dummy_coro(), task_type='system')
w2 = manager.create_task(dummy_coro(), task_type='user')
w3 = manager.create_task(dummy_coro(), task_type='user')
result = manager.get_tasks_dict(type='user')
assert len(result['tasks']) == 2
for t in result['tasks']:
assert t['task_type'] == 'user'
w1.cancel()
w2.cancel()
w3.cancel()
@pytest.mark.asyncio
async def test_cancel_by_scope(self):
"""Test cancel_by_scope cancels matching tasks."""
_, _, AsyncTaskManager = get_taskmgr_classes()
mock_app = create_mock_app()
manager = AsyncTaskManager(mock_app)
async def long_coro():
await asyncio.sleep(10)
# Create task with APPLICATION scope
w1 = manager.create_task(
long_coro(),
scopes=[MockLifecycleControlScope.APPLICATION]
)
# Create task with different scope
w2 = manager.create_task(
long_coro(),
scopes=[MockLifecycleControlScope.PIPELINE]
)
manager.cancel_by_scope(MockLifecycleControlScope.APPLICATION)
await asyncio.sleep(0.01)
assert w1.task.cancelled() or w1.task.done()
assert not w2.task.done()
w2.cancel()
@pytest.mark.asyncio
async def test_cancel_task_by_id(self):
"""Test cancel_task cancels specific task by ID."""
_, _, AsyncTaskManager = get_taskmgr_classes()
mock_app = create_mock_app()
manager = AsyncTaskManager(mock_app)
async def long_coro():
await asyncio.sleep(10)
w1 = manager.create_task(long_coro())
w2 = manager.create_task(long_coro())
manager.cancel_task(w1.id)
await asyncio.sleep(0.01)
assert w1.task.done()
assert not w2.task.done()
w2.cancel()
@pytest.mark.asyncio
async def test_create_user_task_sets_user_type(self):
"""Test create_user_task sets task_type to 'user'."""
_, _, AsyncTaskManager = get_taskmgr_classes()
mock_app = create_mock_app()
manager = AsyncTaskManager(mock_app)
async def dummy_coro():
await asyncio.sleep(0.01)
wrapper = manager.create_user_task(dummy_coro())
assert wrapper.task_type == 'user'
wrapper.cancel()
@pytest.mark.asyncio
async def test_get_task_by_id(self):
"""Test get_task_by_id returns correct task."""
_, _, AsyncTaskManager = get_taskmgr_classes()
mock_app = create_mock_app()
manager = AsyncTaskManager(mock_app)
async def dummy_coro():
await asyncio.sleep(0.01)
w1 = manager.create_task(dummy_coro())
w2 = manager.create_task(dummy_coro())
found = manager.get_task_by_id(w1.id)
assert found is w1
not_found = manager.get_task_by_id(9999)
assert not_found is None
w1.cancel()
w2.cancel()