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
@@ -176,6 +176,38 @@ class TestPathTraversalPrevention:
assert loaded == content
await provider.delete(key)
@pytest.mark.asyncio
async def test_delete_dir_recursive_non_existing_dir(self, storage_provider):
"""delete_dir_recursive should handle non-existing directories gracefully."""
provider, storage_path = storage_provider
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
# Try to delete a non-existing directory - should not raise
await provider.delete_dir_recursive("nonexistent_dir")
@pytest.mark.asyncio
async def test_delete_dir_recursive_with_files(self, storage_provider):
"""delete_dir_recursive should delete directory with files inside."""
provider, storage_path = storage_provider
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
# Create a directory with files
key1 = "test_dir/file1.txt"
key2 = "test_dir/file2.txt"
await provider.save(key1, b"content1")
await provider.save(key2, b"content2")
# Verify files exist
assert await provider.exists(key1)
assert await provider.exists(key2)
# Delete directory recursively
await provider.delete_dir_recursive("test_dir")
# Verify files no longer exist
assert not await provider.exists(key1)
assert not await provider.exists(key2)
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+328
View File
@@ -0,0 +1,328 @@
"""Unit tests for S3StorageProvider.
Tests cover:
- S3 client initialization with bucket creation
- CRUD operations (save, load, exists, delete, size)
- Recursive directory deletion
- Error handling for various S3 errors
Uses moto library to mock AWS S3 service.
"""
from __future__ import annotations
import pytest
from unittest.mock import Mock
from importlib import import_module
def get_s3storage_module():
"""Lazy import to avoid circular import issues."""
return import_module('langbot.pkg.storage.providers.s3storage')
@pytest.fixture
def mock_app_with_s3_config():
"""Create mock app with S3 configuration."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {
'storage': {
's3': {
'endpoint_url': '',
'access_key_id': 'testing',
'secret_access_key': 'testing',
'region': 'us-east-1',
'bucket': 'test-langbot-storage',
}
}
}
mock_app.logger = Mock()
return mock_app
@pytest.fixture
def s3_mock():
"""Set up moto S3 mock context."""
from moto import mock_aws
with mock_aws():
import boto3
# Create bucket for tests that need pre-existing bucket
s3 = boto3.client('s3', region_name='us-east-1')
yield s3
class TestS3StorageProviderInit:
"""Tests for S3StorageProvider initialization."""
def test_init_stores_app_reference(self):
"""Test that __init__ stores the Application reference."""
s3storage = get_s3storage_module()
mock_app = Mock()
provider = s3storage.S3StorageProvider(mock_app)
assert provider.ap is mock_app
def test_init_s3_client_none(self):
"""Test that s3_client starts as None."""
s3storage = get_s3storage_module()
mock_app = Mock()
provider = s3storage.S3StorageProvider(mock_app)
assert provider.s3_client is None
assert provider.bucket_name is None
class TestS3StorageProviderWithMoto:
"""Tests using moto to mock AWS S3."""
@pytest.mark.asyncio
async def test_initialize_creates_bucket_when_not_exists(self, mock_app_with_s3_config, s3_mock):
"""Test that initialize creates bucket when it doesn't exist."""
s3storage = get_s3storage_module()
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
assert provider.s3_client is not None
assert provider.bucket_name == 'test-langbot-storage'
mock_app_with_s3_config.logger.info.assert_called()
@pytest.mark.asyncio
async def test_initialize_uses_existing_bucket(self, mock_app_with_s3_config, s3_mock):
"""Test that initialize uses existing bucket without creating."""
s3storage = get_s3storage_module()
# Pre-create bucket in mock
s3_mock.create_bucket(Bucket='test-langbot-storage')
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
assert provider.s3_client is not None
# Bucket creation log should not be called since bucket exists
# Note: moto may still call head_bucket successfully
@pytest.mark.asyncio
async def test_save_and_load_bytes(self, mock_app_with_s3_config, s3_mock):
"""Test that save and load work correctly."""
s3storage = get_s3storage_module()
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
# Save data
test_data = b'Hello, S3!'
await provider.save('test/file.txt', test_data)
# Load data
loaded_data = await provider.load('test/file.txt')
assert loaded_data == test_data
@pytest.mark.asyncio
async def test_exists_returns_true_for_existing_object(self, mock_app_with_s3_config, s3_mock):
"""Test that exists returns True for existing object."""
s3storage = get_s3storage_module()
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
# Save data
await provider.save('test/file.txt', b'data')
# Check existence
result = await provider.exists('test/file.txt')
assert result is True
@pytest.mark.asyncio
async def test_exists_returns_false_for_nonexistent_object(self, mock_app_with_s3_config, s3_mock):
"""Test that exists returns False for nonexistent object."""
s3storage = get_s3storage_module()
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
# Check existence without saving
result = await provider.exists('nonexistent/file.txt')
assert result is False
@pytest.mark.asyncio
async def test_delete_removes_object(self, mock_app_with_s3_config, s3_mock):
"""Test that delete removes object."""
s3storage = get_s3storage_module()
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
# Save data
await provider.save('test/file.txt', b'data')
# Delete
await provider.delete('test/file.txt')
# Check existence
result = await provider.exists('test/file.txt')
assert result is False
@pytest.mark.asyncio
async def test_size_returns_content_length(self, mock_app_with_s3_config, s3_mock):
"""Test that size returns correct content length."""
s3storage = get_s3storage_module()
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
# Save data
test_data = b'12345' # 5 bytes
await provider.save('test/file.txt', test_data)
# Get size
size = await provider.size('test/file.txt')
assert size == 5
@pytest.mark.asyncio
async def test_delete_dir_recursive_removes_all_objects(self, mock_app_with_s3_config, s3_mock):
"""Test that delete_dir_recursive removes all objects with prefix."""
s3storage = get_s3storage_module()
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
# Save multiple objects in directory
await provider.save('testdir/file1.txt', b'data1')
await provider.save('testdir/file2.txt', b'data2')
await provider.save('testdir/subdir/file3.txt', b'data3')
await provider.save('otherdir/file.txt', b'data4')
# Delete directory
await provider.delete_dir_recursive('testdir')
# Verify testdir objects are deleted
assert await provider.exists('testdir/file1.txt') is False
assert await provider.exists('testdir/file2.txt') is False
assert await provider.exists('testdir/subdir/file3.txt') is False
# Verify other directory is intact
assert await provider.exists('otherdir/file.txt') is True
@pytest.mark.asyncio
async def test_delete_dir_recursive_handles_trailing_slash(self, mock_app_with_s3_config, s3_mock):
"""Test that delete_dir_recursive handles path without trailing slash."""
s3storage = get_s3storage_module()
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
# Save object
await provider.save('mydir/file.txt', b'data')
# Delete without trailing slash
await provider.delete_dir_recursive('mydir')
# Verify deleted
assert await provider.exists('mydir/file.txt') is False
@pytest.mark.asyncio
async def test_delete_dir_recursive_empty_directory(self, mock_app_with_s3_config, s3_mock):
"""Test that delete_dir_recursive handles empty directory."""
s3storage = get_s3storage_module()
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
# Delete non-existent directory should not raise
await provider.delete_dir_recursive('emptydir')
@pytest.mark.asyncio
async def test_multiple_saves_and_loads(self, mock_app_with_s3_config, s3_mock):
"""Test multiple save/load operations."""
s3storage = get_s3storage_module()
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
# Save multiple files
files = {
'file1.txt': b'content1',
'file2.txt': b'content2',
'dir/file3.txt': b'content3',
}
for key, data in files.items():
await provider.save(key, data)
# Load and verify all
for key, expected in files.items():
loaded = await provider.load(key)
assert loaded == expected
@pytest.mark.asyncio
async def test_overwrite_existing_object(self, mock_app_with_s3_config, s3_mock):
"""Test that save overwrites existing object."""
s3storage = get_s3storage_module()
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
# Save initial data
await provider.save('file.txt', b'initial')
# Overwrite
await provider.save('file.txt', b'overwritten')
# Verify new content
loaded = await provider.load('file.txt')
assert loaded == b'overwritten'
class TestS3StorageProviderErrorHandling:
"""Tests for error handling scenarios."""
@pytest.mark.asyncio
async def test_load_nonexistent_raises_error(self, s3_mock):
"""Test that load raises error for nonexistent object."""
s3storage = get_s3storage_module()
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {
'storage': {
's3': {
'bucket': 'test-bucket',
'access_key_id': 'testing',
'secret_access_key': 'testing',
'region': 'us-east-1',
}
}
}
mock_app.logger = Mock()
provider = s3storage.S3StorageProvider(mock_app)
await provider.initialize()
with pytest.raises(Exception):
await provider.load('nonexistent.txt')
@pytest.mark.asyncio
async def test_size_nonexistent_raises_error(self, s3_mock):
"""Test that size raises error for nonexistent object."""
s3storage = get_s3storage_module()
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {
'storage': {
's3': {
'bucket': 'test-bucket',
'access_key_id': 'testing',
'secret_access_key': 'testing',
'region': 'us-east-1',
}
}
}
mock_app.logger = Mock()
provider = s3storage.S3StorageProvider(mock_app)
await provider.initialize()
with pytest.raises(Exception):
await provider.size('nonexistent.txt')
@@ -0,0 +1,126 @@
"""
Tests for langbot.pkg.storage.mgr module.
Tests storage manager initialization and provider selection.
"""
import pytest
from unittest.mock import Mock, AsyncMock, patch
from langbot.pkg.storage.mgr import StorageMgr
from langbot.pkg.storage.providers.localstorage import LocalStorageProvider
from langbot.pkg.storage.providers.s3storage import S3StorageProvider
class TestStorageMgr:
"""Test StorageMgr class."""
def test_init_stores_app_reference(self):
"""StorageMgr should store the application reference."""
mock_app = Mock()
storage_mgr = StorageMgr(mock_app)
assert storage_mgr.ap == mock_app
@pytest.mark.asyncio
async def test_initialize_default_local(self):
"""Should use local storage by default."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {}
mock_app.logger = Mock()
storage_mgr = StorageMgr(mock_app)
with patch.object(LocalStorageProvider, "initialize", new_callable=AsyncMock):
await storage_mgr.initialize()
assert isinstance(storage_mgr.storage_provider, LocalStorageProvider)
mock_app.logger.info.assert_called()
@pytest.mark.asyncio
async def test_initialize_with_explicit_local(self):
"""Should use local storage when explicitly configured."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {"storage": {"use": "local"}}
mock_app.logger = Mock()
storage_mgr = StorageMgr(mock_app)
with patch.object(LocalStorageProvider, "initialize", new_callable=AsyncMock):
await storage_mgr.initialize()
assert isinstance(storage_mgr.storage_provider, LocalStorageProvider)
@pytest.mark.asyncio
async def test_initialize_with_s3(self):
"""Should use S3 storage when configured."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {
"storage": {"use": "s3", "s3": {"endpoint_url": "https://s3.amazonaws.com"}}
}
mock_app.logger = Mock()
storage_mgr = StorageMgr(mock_app)
with patch.object(S3StorageProvider, "initialize", new_callable=AsyncMock):
await storage_mgr.initialize()
assert isinstance(storage_mgr.storage_provider, S3StorageProvider)
@pytest.mark.asyncio
async def test_initialize_invalid_type_defaults_to_local(self):
"""Should default to local storage for invalid storage type."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {"storage": {"use": "invalid_type"}}
mock_app.logger = Mock()
storage_mgr = StorageMgr(mock_app)
with patch.object(LocalStorageProvider, "initialize", new_callable=AsyncMock):
await storage_mgr.initialize()
assert isinstance(storage_mgr.storage_provider, LocalStorageProvider)
@pytest.mark.asyncio
async def test_initialize_calls_provider_initialize(self):
"""Should call the provider's initialize method."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {}
mock_app.logger = Mock()
storage_mgr = StorageMgr(mock_app)
with patch.object(
LocalStorageProvider, "initialize", new_callable=AsyncMock
) as mock_init:
await storage_mgr.initialize()
mock_init.assert_called_once()
class TestStorageProviderBase:
"""Test StorageProvider base class methods."""
def test_provider_stores_app_reference(self):
"""Provider should store app reference."""
mock_app = Mock()
# Use LocalStorageProvider as concrete implementation
with patch("os.path.exists", return_value=True):
with patch("os.makedirs"):
provider = LocalStorageProvider(mock_app)
assert provider.ap == mock_app
@pytest.mark.asyncio
async def test_provider_base_initialize(self):
"""Provider base initialize should be callable and do nothing."""
mock_app = Mock()
with patch("os.path.exists", return_value=True):
with patch("os.makedirs"):
provider = LocalStorageProvider(mock_app)
# Initialize should not raise
await provider.initialize()
if __name__ == "__main__":
pytest.main([__file__, "-v"])