Files
LangBot/tests/unit_tests/plugin/test_connector_pure.py
huanghuoguoguo 70ec75f9a2 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>
2026-05-16 10:12:48 +08:00

151 lines
5.3 KiB
Python

"""Tests for PluginRuntimeConnector pure logic methods.
Tests methods that don't require real plugin runtime processes:
- _extract_deps_metadata: deps extraction from zip files
- _parse_plugin_id: plugin ID string parsing
"""
from __future__ import annotations
import io
import zipfile
from types import SimpleNamespace
from unittest.mock import MagicMock
class TestExtractDepsMetadata:
"""Tests for _extract_deps_metadata method."""
def _create_connector(self):
"""Create a connector instance for testing."""
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
mock_app = MagicMock()
mock_app.instance_config.data.get.return_value = {'enable': True}
mock_app.logger = MagicMock()
connector = PluginRuntimeConnector(mock_app, MagicMock())
return connector
def test_extract_deps_with_requirements_txt(self):
"""Extract dependency count from requirements.txt in plugin zip."""
connector = self._create_connector()
# Create a mock zip file with requirements.txt
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w') as zf:
zf.writestr('requirements.txt', 'requests>=2.0\nflask\n# comment\n\nnumpy')
zip_bytes = zip_buffer.getvalue()
task_context = SimpleNamespace(metadata={})
connector._extract_deps_metadata(zip_bytes, task_context)
assert task_context.metadata['deps_total'] == 3 # requests>=2.0, flask, numpy
# deps_list contains full requirement lines including version specifiers
assert 'requests>=2.0' in task_context.metadata['deps_list']
assert 'flask' in task_context.metadata['deps_list']
assert 'numpy' in task_context.metadata['deps_list']
def test_extract_deps_empty_requirements(self):
"""Handle empty requirements.txt."""
connector = self._create_connector()
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w') as zf:
zf.writestr('requirements.txt', '# only comments\n\n')
zip_bytes = zip_buffer.getvalue()
task_context = SimpleNamespace(metadata={})
connector._extract_deps_metadata(zip_bytes, task_context)
assert task_context.metadata['deps_total'] == 0
assert task_context.metadata['deps_list'] == []
def test_extract_deps_no_requirements_txt(self):
"""Handle zip without requirements.txt."""
connector = self._create_connector()
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w') as zf:
zf.writestr('plugin.py', 'print("hello")')
zip_bytes = zip_buffer.getvalue()
task_context = SimpleNamespace(metadata={})
connector._extract_deps_metadata(zip_bytes, task_context)
# No requirements.txt found, metadata unchanged
assert 'deps_total' not in task_context.metadata
def test_extract_deps_none_task_context(self):
"""Handle None task_context gracefully."""
connector = self._create_connector()
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w') as zf:
zf.writestr('requirements.txt', 'requests')
zip_bytes = zip_buffer.getvalue()
# Should return early without error
connector._extract_deps_metadata(zip_bytes, None)
def test_extract_deps_invalid_zip(self):
"""Handle invalid zip file gracefully."""
connector = self._create_connector()
# Not a valid zip
invalid_bytes = b'not a zip file'
task_context = SimpleNamespace(metadata={})
connector._extract_deps_metadata(invalid_bytes, task_context)
# Should catch exception and pass silently
assert 'deps_total' not in task_context.metadata
def test_extract_deps_nested_requirements(self):
"""Handle requirements.txt in nested directory."""
connector = self._create_connector()
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w') as zf:
zf.writestr('subdir/requirements.txt', 'pytest\nblack')
zip_bytes = zip_buffer.getvalue()
task_context = SimpleNamespace(metadata={})
connector._extract_deps_metadata(zip_bytes, task_context)
# Should find requirements.txt in subdirectory
assert task_context.metadata['deps_total'] == 2
class TestParsePluginId:
"""Tests for _parse_plugin_id static method."""
def test_parse_valid_plugin_id(self):
"""Parse valid plugin ID format 'author/name'."""
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
author, name = PluginRuntimeConnector._parse_plugin_id('myauthor/myplugin')
assert author == 'myauthor'
assert name == 'myplugin'
def test_parse_plugin_id_with_multiple_slashes(self):
"""Parse plugin ID with multiple slashes uses split('/', 1)."""
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
# split('/', 1) only splits on first slash
author, name = PluginRuntimeConnector._parse_plugin_id('org/author/plugin-name')
assert author == 'org'
assert name == 'author/plugin-name'
def test_parse_plugin_id_empty(self):
"""Handle empty plugin ID."""
# Empty string behavior
parts = ''.split('/')
assert len(parts) == 1
assert parts[0] == ''