""" Environment and configuration tests. These tests verify the application behaves correctly with different configuration settings, environment variables, and Docker setups. Run with: uv run pytest tests/config/test_environment.py -v """ import importlib import os import tempfile from pathlib import Path from unittest.mock import patch 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 CONTENT_TYPES, get_ingest_dir # 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 ( load_config_file, save_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 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 def test_request_policy_loader_reads_env_backed_values(self, monkeypatch): """Request-policy helpers should read effective values via the config singleton.""" from shelfmark.core.config import config from shelfmark.core.request_helpers import load_users_request_policy_settings monkeypatch.setenv("REQUESTS_ENABLED", "true") monkeypatch.setenv("REQUEST_POLICY_DEFAULT_EBOOK", "blocked") config.refresh(force=True) try: settings = load_users_request_policy_settings() assert settings["REQUESTS_ENABLED"] is True assert settings["REQUEST_POLICY_DEFAULT_EBOOK"] == "blocked" finally: monkeypatch.delenv("REQUESTS_ENABLED", raising=False) monkeypatch.delenv("REQUEST_POLICY_DEFAULT_EBOOK", raising=False) config.refresh(force=True) # ============================================================================= # 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_flask_port_env_var_raises_value_error_on_reload(self, monkeypatch): """Invalid FLASK_PORT values should fail fast when env.py reloads.""" import shelfmark.config.env as env_module original_port = os.environ.get("FLASK_PORT") try: monkeypatch.setenv("FLASK_PORT", "not_a_number") with pytest.raises(ValueError, match="invalid literal for int"): importlib.reload(env_module) finally: if original_port is None: monkeypatch.delenv("FLASK_PORT", raising=False) else: monkeypatch.setenv("FLASK_PORT", original_port) importlib.reload(env_module) 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): 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, monkeypatch): """LOG_LEVEL should follow the effective DEBUG value on reload.""" import shelfmark.config.env as env_module original_debug = os.environ.get("DEBUG") try: monkeypatch.setenv("DEBUG", "true") importlib.reload(env_module) assert env_module.DEBUG is True assert env_module.LOG_LEVEL == "DEBUG" monkeypatch.setenv("DEBUG", "false") importlib.reload(env_module) assert env_module.DEBUG is False assert env_module.LOG_LEVEL == "INFO" finally: if original_debug is None: monkeypatch.delenv("DEBUG", raising=False) else: monkeypatch.setenv("DEBUG", original_debug) importlib.reload(env_module) # ============================================================================= # 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()