""" Environment and configuration tests. These tests verify the application behaves correctly with different configuration settings, environment variables, and Docker setups. Run with: docker exec test-cwabd python3 -m pytest /app/tests/config/test_environment.py -v """ import json import os import shutil import tempfile from pathlib import Path from unittest.mock import patch, MagicMock import pytest # ============================================================================= # Directory Setup Tests # ============================================================================= class TestDirectorySetup: """Tests for directory creation and permissions.""" def test_staging_dir_created_on_demand(self): """Staging directory should be created if it doesn't exist.""" from shelfmark.download.staging import get_staging_dir with tempfile.TemporaryDirectory() as tmpdir: test_staging = Path(tmpdir) / "staging" assert not test_staging.exists() with patch("shelfmark.config.env.TMP_DIR", test_staging): result = get_staging_dir() assert test_staging.exists() assert result == test_staging def test_staging_dir_handles_existing_directory(self): """Staging directory creation should be idempotent.""" from shelfmark.download.staging import get_staging_dir with tempfile.TemporaryDirectory() as tmpdir: test_staging = Path(tmpdir) / "staging" test_staging.mkdir() with patch("shelfmark.config.env.TMP_DIR", test_staging): result = get_staging_dir() assert result == test_staging def test_staging_path_handles_special_characters(self): """Staging path should handle task IDs with special characters.""" from shelfmark.download.staging import get_staging_path with tempfile.TemporaryDirectory() as tmpdir: with patch( "shelfmark.config.env.TMP_DIR", Path(tmpdir) ): # Task ID with URL-like characters path = get_staging_path( "https://example.com/book?id=123&format=epub", "epub" ) assert path.suffix == ".epub" assert path.parent == Path(tmpdir) # Should not contain invalid filename chars assert "/" not in path.name assert "?" not in path.name assert "&" not in path.name def test_staging_path_normalizes_extension(self): """Staging path should handle extensions with or without dot.""" from shelfmark.download.staging import get_staging_path with tempfile.TemporaryDirectory() as tmpdir: with patch( "shelfmark.config.env.TMP_DIR", Path(tmpdir) ): path1 = get_staging_path("task1", "epub") path2 = get_staging_path("task1", ".epub") assert path1.suffix == ".epub" assert path2.suffix == ".epub" # ============================================================================= # Supported Formats Tests # ============================================================================= class TestSupportedFormats: """Tests for format filtering configuration.""" def test_default_supported_formats(self): """Default formats should include common ebook formats.""" from shelfmark.core.config import config # Ensure settings are refreshed to pick up defaults config.refresh() formats = config.get("SUPPORTED_FORMATS", []) # Check some expected defaults assert "epub" in formats assert "mobi" in formats assert "azw3" in formats def test_format_list_is_lowercase(self): """Format list should be normalized to lowercase.""" from shelfmark.core.config import config # Ensure settings are refreshed to pick up defaults config.refresh() formats = config.get("SUPPORTED_FORMATS", []) # All formats should be lowercase for fmt in formats: assert fmt == fmt.lower() def test_config_supported_formats_is_list(self): """Config should have SUPPORTED_FORMATS as a list.""" from shelfmark.core.config import config # Ensure settings are refreshed to pick up defaults config.refresh() formats = config.get("SUPPORTED_FORMATS", []) assert isinstance(formats, list) assert len(formats) > 0 assert "epub" in formats # ============================================================================= # Content-Type Routing Tests # ============================================================================= class TestContentTypeRouting: """Tests for content-type based directory routing.""" def test_get_ingest_dir_returns_path(self): """get_ingest_dir should return a Path for all content types.""" from shelfmark.core.utils import get_ingest_dir, CONTENT_TYPES # Default (no content type) should return a Path default_path = get_ingest_dir() assert isinstance(default_path, Path) # All content types should return a Path for content_type in CONTENT_TYPES: path = get_ingest_dir(content_type) assert isinstance(path, Path) def test_content_types_list_complete(self): """All expected content types should be present in CONTENT_TYPES.""" from shelfmark.core.utils import CONTENT_TYPES expected_types = [ "book (fiction)", "book (non-fiction)", "book (unknown)", "magazine", "comic book", "audiobook", "standards document", "musical score", "other", ] for content_type in expected_types: assert content_type in CONTENT_TYPES, f"Missing content type: {content_type}" def test_get_ingest_dir_unknown_type_returns_default(self): """Unknown content types should return the default ingest directory.""" from shelfmark.core.utils import get_ingest_dir default_path = get_ingest_dir() unknown_path = get_ingest_dir("unknown content type") assert unknown_path == default_path # ============================================================================= # Settings System Tests # ============================================================================= class TestSettingsSystem: """Tests for the settings registry and persistence.""" def test_save_and_load_config(self): """Settings should persist to JSON files.""" from shelfmark.core.settings_registry import ( save_config_file, load_config_file, ) with tempfile.TemporaryDirectory() as tmpdir: with patch( "shelfmark.config.env.CONFIG_DIR", Path(tmpdir) ): test_data = {"key1": "value1", "key2": 123, "key3": True} save_config_file("test_plugin", test_data) loaded = load_config_file("test_plugin") assert loaded == test_data def test_load_missing_config_returns_empty(self): """Loading non-existent config should return empty dict.""" from shelfmark.core.settings_registry import load_config_file with tempfile.TemporaryDirectory() as tmpdir: with patch( "shelfmark.config.env.CONFIG_DIR", Path(tmpdir) ): loaded = load_config_file("nonexistent_plugin") assert loaded == {} def test_config_singleton_refresh(self): """Config singleton should refresh when settings change.""" from shelfmark.core.config import config from shelfmark.core.settings_registry import save_config_file # Get initial value initial = config.get("TEST_REFRESH_KEY", "default") with tempfile.TemporaryDirectory() as tmpdir: with patch( "shelfmark.config.env.CONFIG_DIR", Path(tmpdir) ): save_config_file("test", {"TEST_REFRESH_KEY": "new_value"}) config.refresh() # Note: This test is limited because config also reads from env def test_config_env_var_priority(self): """Environment variables should take priority over config files.""" # This tests the priority: ENV > config file > default from shelfmark.config.env import string_to_bool # Test the string_to_bool helper used for parsing assert string_to_bool("true") is True assert string_to_bool("True") is True assert string_to_bool("TRUE") is True assert string_to_bool("yes") is True assert string_to_bool("1") is True assert string_to_bool("y") is True assert string_to_bool("false") is False assert string_to_bool("no") is False assert string_to_bool("0") is False assert string_to_bool("anything_else") is False # ============================================================================= # Archive Handling Configuration Tests # ============================================================================= class TestArchiveHandling: """Tests for archive extraction configuration.""" def test_is_archive_detects_supported_formats(self): """is_archive should detect RAR and ZIP files (not cbr/cbz which are book formats).""" from shelfmark.download.archive import is_archive # RAR and ZIP are archive formats that get extracted assert is_archive(Path("book.rar")) is True assert is_archive(Path("book.zip")) is True # CBR/CBZ are comic book formats, treated as books not archives assert is_archive(Path("book.cbr")) is False assert is_archive(Path("book.cbz")) is False # Regular book formats are not archives assert is_archive(Path("book.epub")) is False assert is_archive(Path("book.pdf")) is False assert is_archive(Path("book.mobi")) is False def test_is_archive_case_insensitive(self): """Archive detection should be case insensitive.""" from shelfmark.download.archive import is_archive assert is_archive(Path("book.RAR")) is True assert is_archive(Path("book.ZIP")) is True assert is_archive(Path("book.Zip")) is True assert is_archive(Path("book.RaR")) is True # ============================================================================= # Validation and Error Handling Tests # ============================================================================= class TestConfigValidation: """Tests for configuration validation and error handling.""" def test_invalid_number_env_var_uses_default(self): """Invalid numeric env vars should fall back to defaults.""" # Test that int() parsing handles invalid values gracefully # The env.py module uses int() which will raise ValueError # This tests the expected behavior with patch.dict(os.environ, {"MAX_RETRY": "not_a_number"}): # Importing with invalid env var should use default or raise # This depends on implementation - test documents behavior pass # Currently env.py will crash on invalid int def test_missing_required_directory_handling(self): """Application should handle missing directories gracefully.""" from shelfmark.download.staging import get_staging_dir with tempfile.TemporaryDirectory() as tmpdir: # Use a path that doesn't exist yet nonexistent = Path(tmpdir) / "deeply" / "nested" / "path" with patch( "shelfmark.config.env.TMP_DIR", nonexistent ): result = get_staging_dir() # Should have created the directory assert nonexistent.exists() @pytest.mark.skipif( os.geteuid() == 0, reason="Test skipped when running as root (chmod has no effect)" ) def test_config_dir_not_writable(self): """Application should handle read-only config directory.""" from shelfmark.config.env import _is_config_dir_writable with tempfile.TemporaryDirectory() as tmpdir: readonly_dir = Path(tmpdir) / "readonly" readonly_dir.mkdir() os.chmod(readonly_dir, 0o444) # Read-only try: with patch( "shelfmark.config.env.CONFIG_DIR", readonly_dir ): result = _is_config_dir_writable() assert result is False finally: os.chmod(readonly_dir, 0o755) # Restore for cleanup # ============================================================================= # Settings Validation Tests # ============================================================================= class TestSettingsValidation: """Tests for settings save-time validation.""" def test_downloads_books_rename_template_rejects_path_separators(self): import shelfmark.config.settings # noqa: F401 from shelfmark.core.settings_registry import update_settings result = update_settings( "downloads", { "FILE_ORGANIZATION": "rename", "TEMPLATE_RENAME": "{Author}/{Title}", }, ) assert result["success"] is False assert "Naming Template" in result["message"] assert "Organize" in result["message"] def test_downloads_audiobooks_rename_template_rejects_path_separators(self): import shelfmark.config.settings # noqa: F401 from shelfmark.core.settings_registry import update_settings result = update_settings( "downloads", { "FILE_ORGANIZATION_AUDIOBOOK": "rename", "TEMPLATE_AUDIOBOOK_RENAME": "{Author}/{Title}", }, ) assert result["success"] is False assert "Naming Template" in result["message"] assert "Organize" in result["message"] def test_downloads_books_rename_validation_uses_existing_values(self): import shelfmark.config.settings # noqa: F401 from shelfmark.core.settings_registry import update_settings with patch( "shelfmark.config.settings.load_config_file", return_value={ "BOOKS_OUTPUT_MODE": "folder", "TEMPLATE_RENAME": "{Author}/{Title}", }, ): result = update_settings( "downloads", { "FILE_ORGANIZATION": "rename", }, ) assert result["success"] is False assert "Naming Template" in result["message"] # ============================================================================= # Debug and Logging Configuration Tests # ============================================================================= class TestDebugConfiguration: """Tests for debug and logging settings.""" def test_debug_from_env_var(self): """DEBUG env var should set debug mode.""" from shelfmark.config.env import string_to_bool # Test the parsing logic assert string_to_bool("true") is True assert string_to_bool("false") is False def test_log_level_derived_from_debug(self): """LOG_LEVEL should be derived from DEBUG setting.""" # When DEBUG is True, LOG_LEVEL should be "DEBUG" # When DEBUG is False, LOG_LEVEL should be "INFO" # This is tested by checking the module logic pass # The logic is in env.py: LOG_LEVEL = "DEBUG" if DEBUG else "INFO" # ============================================================================= # Proxy and Network Configuration Tests # ============================================================================= class TestNetworkConfiguration: """Tests for proxy and network settings.""" def test_proxy_settings_default(self): """Proxy settings should have sensible defaults.""" from shelfmark.core.config import config config.refresh() # Default proxy mode should be 'none' (no proxy) assert config.get("PROXY_MODE", "none") == "none" def test_tor_mode_is_detected(self): """Tor mode should be detected from container variant.""" from shelfmark.config.env import TOR_VARIANT_AVAILABLE # In regular test environment, Tor should not be available # (unless running in Tor container) assert isinstance(TOR_VARIANT_AVAILABLE, bool) # ============================================================================= # Concurrent Downloads Configuration Tests # ============================================================================= class TestConcurrencyConfiguration: """Tests for concurrent download settings.""" def test_max_concurrent_downloads_default(self): """MAX_CONCURRENT_DOWNLOADS should have a sensible default.""" from shelfmark.core.config import config config.refresh() max_downloads = config.get("MAX_CONCURRENT_DOWNLOADS", 3) assert max_downloads >= 1 assert max_downloads <= 10 # Reasonable upper bound def test_download_progress_interval_default(self): """DOWNLOAD_PROGRESS_UPDATE_INTERVAL should have a sensible default.""" from shelfmark.core.config import config config.refresh() interval = config.get("DOWNLOAD_PROGRESS_UPDATE_INTERVAL", 1) assert interval >= 1 assert interval <= 10 # ============================================================================= # Cache Configuration Tests # ============================================================================= class TestCacheConfiguration: """Tests for cache settings.""" def test_metadata_cache_ttl_defaults(self): """Metadata cache TTLs should have sensible defaults.""" from shelfmark.core.config import config config.refresh() search_ttl = config.get("METADATA_CACHE_SEARCH_TTL", 300) book_ttl = config.get("METADATA_CACHE_BOOK_TTL", 600) # Search cache should be shorter than book cache assert search_ttl > 0 assert book_ttl > 0 assert search_ttl <= book_ttl def test_covers_cache_directory(self): """Covers cache directory should be under CONFIG_DIR.""" from shelfmark.config.env import CONFIG_DIR covers_dir = CONFIG_DIR / "covers" assert covers_dir.parent == CONFIG_DIR assert covers_dir.name == "covers" # ============================================================================= # File Collision Handling Tests # ============================================================================= class TestFileCollisionHandling: """Tests for handling file name collisions.""" def test_stage_file_handles_collision(self): """stage_file should add suffix on collision.""" from shelfmark.download.staging import stage_file with tempfile.TemporaryDirectory() as tmpdir: staging = Path(tmpdir) / "staging" staging.mkdir() # Create source file source = Path(tmpdir) / "book.epub" source.write_text("content") # Create existing file with same name in staging (staging / "book.epub").write_text("existing") with patch( "shelfmark.config.env.TMP_DIR", staging ): result = stage_file(source, "task1", copy=True) # Should have created a new file with suffix assert result.name == "book_1.epub" assert result.exists() def test_stage_file_copy_vs_move(self): """stage_file should copy or move based on parameter.""" from shelfmark.download.staging import stage_file with tempfile.TemporaryDirectory() as tmpdir: staging = Path(tmpdir) / "staging" staging.mkdir() # Test copy source1 = Path(tmpdir) / "book1.epub" source1.write_text("content1") with patch( "shelfmark.config.env.TMP_DIR", staging ): result1 = stage_file(source1, "task1", copy=True) assert source1.exists() # Original still exists assert result1.exists() # Test move source2 = Path(tmpdir) / "book2.epub" source2.write_text("content2") with patch( "shelfmark.config.env.TMP_DIR", staging ): result2 = stage_file(source2, "task2", copy=False) assert not source2.exists() # Original moved assert result2.exists()