Files
LangBot/tests/unit_tests/plugin/test_extract_deps.py
2026-05-16 10:30:17 +08:00

211 lines
7.1 KiB
Python

"""Unit tests for plugin connector _inspect_plugin_package method.
Tests cover:
- Extracting requirements.txt from ZIP
- Parsing dependency lines
- Handling missing requirements.txt
- Handling empty/malformed requirements.txt
"""
from __future__ import annotations
import zipfile
import io
from unittest.mock import Mock
from importlib import import_module
def get_connector_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.plugin.connector')
def create_mock_connector():
"""Create a mock PluginRuntimeConnector instance for testing."""
connector = get_connector_module()
mock_app = Mock()
mock_app.logger = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {'plugin': {'enable': True}}
# Mock disconnect callback
async def mock_disconnect_callback(connector):
pass
return connector.PluginRuntimeConnector(mock_app, mock_disconnect_callback)
def create_zip_with_requirements(requirements_content: str) -> bytes:
"""Create a ZIP file containing requirements.txt with given content."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr('requirements.txt', requirements_content)
return buf.getvalue()
def create_zip_with_nested_requirements(requirements_content: str) -> bytes:
"""Create a ZIP file with requirements.txt in nested directory."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr('plugin/requirements.txt', requirements_content)
return buf.getvalue()
def create_zip_without_requirements() -> bytes:
"""Create a ZIP file without requirements.txt."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr('main.py', 'print("hello")')
zf.writestr('manifest.yaml', 'name: test')
return buf.getvalue()
class TestExtractDepsMetadata:
"""Tests for dependency metadata extraction from plugin packages."""
def test_extract_simple_requirements(self):
"""Test extracting simple requirements.txt."""
connector_instance = create_mock_connector()
# Create test ZIP
zip_bytes = create_zip_with_requirements('requests>=2.0\nflask==1.0\nnumpy')
# Create task context
task_context = Mock()
task_context.metadata = {}
connector_instance._inspect_plugin_package(zip_bytes, task_context)
assert task_context.metadata.get('deps_total') == 3
assert task_context.metadata.get('deps_list') == ['requests>=2.0', 'flask==1.0', 'numpy']
def test_extract_requirements_with_comments_and_empty_lines(self):
"""Test that comments and empty lines are filtered."""
connector_instance = create_mock_connector()
requirements = '''# This is a comment
requests>=2.0
# Another comment
flask==1.0
numpy'''
zip_bytes = create_zip_with_requirements(requirements)
task_context = Mock()
task_context.metadata = {}
connector_instance._inspect_plugin_package(zip_bytes, task_context)
assert task_context.metadata.get('deps_total') == 3
assert '# This is a comment' not in task_context.metadata.get('deps_list', [])
def test_extract_nested_requirements(self):
"""Test extracting requirements.txt from nested directory."""
connector_instance = create_mock_connector()
zip_bytes = create_zip_with_nested_requirements('requests\nflask')
task_context = Mock()
task_context.metadata = {}
connector_instance._inspect_plugin_package(zip_bytes, task_context)
# Should find nested requirements.txt (ends with 'requirements.txt')
assert task_context.metadata.get('deps_total') == 2
def test_no_requirements_in_zip(self):
"""Test handling ZIP without requirements.txt."""
connector_instance = create_mock_connector()
zip_bytes = create_zip_without_requirements()
task_context = Mock()
task_context.metadata = {}
connector_instance._inspect_plugin_package(zip_bytes, task_context)
# metadata should remain empty (no deps found)
assert task_context.metadata.get('deps_total') is None
assert task_context.metadata.get('deps_list') is None
def test_empty_requirements_file(self):
"""Test handling empty requirements.txt."""
connector_instance = create_mock_connector()
zip_bytes = create_zip_with_requirements('')
task_context = Mock()
task_context.metadata = {}
connector_instance._inspect_plugin_package(zip_bytes, task_context)
# deps_total should be 0 (empty list after filtering)
assert task_context.metadata.get('deps_total') == 0
assert task_context.metadata.get('deps_list') == []
def test_requirements_only_comments(self):
"""Test handling requirements.txt with only comments."""
connector_instance = create_mock_connector()
requirements = '''# Comment 1
# Comment 2
# Comment 3'''
zip_bytes = create_zip_with_requirements(requirements)
task_context = Mock()
task_context.metadata = {}
connector_instance._inspect_plugin_package(zip_bytes, task_context)
assert task_context.metadata.get('deps_total') == 0
assert task_context.metadata.get('deps_list') == []
def test_task_context_none_returns_early(self):
"""Test that method returns early when task_context is None."""
connector_instance = create_mock_connector()
zip_bytes = create_zip_with_requirements('requests')
# Should return without error when task_context is None
connector_instance._inspect_plugin_package(zip_bytes, None)
# No exception should be raised
def test_malformed_zip_handling(self):
"""Test handling malformed ZIP bytes."""
connector_instance = create_mock_connector()
# Invalid ZIP bytes
invalid_bytes = b'not a valid zip file'
task_context = Mock()
task_context.metadata = {}
# Should silently handle exception (pass in try/except)
connector_instance._inspect_plugin_package(invalid_bytes, task_context)
# metadata should remain unchanged
assert task_context.metadata == {}
def test_requirements_with_unicode_decode_error(self):
"""Test handling requirements.txt with non-UTF8 content."""
connector_instance = create_mock_connector()
# Create ZIP with non-UTF8 content in requirements.txt
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
# Write bytes that will cause decode issues
# \x80 is invalid UTF-8, but errors='ignore' will skip it
zf.writestr('requirements.txt', b'requests\nflask\n\x80invalid')
zip_bytes = buf.getvalue()
task_context = Mock()
task_context.metadata = {}
# errors='ignore' will decode \x80invalid as 'invalid' (skipping \x80)
connector_instance._inspect_plugin_package(zip_bytes, task_context)
# All 3 lines will be parsed (requests, flask, invalid)
assert task_context.metadata.get('deps_total') == 3
assert 'invalid' in task_context.metadata.get('deps_list', [])