mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-04-20 05:51:21 -04:00
- Added new book output option **upload to Booklore**, available in download settings - Got annoyed at my messy processing code while implementing Booklore so refactored the whole thing - Full black box file processing testing with randomised configuration - Deluge: Connect via WebUI auth for simplified setup - Added env vars documentation, auto generated via script, and unlocked most settings to be used as env vars
580 lines
20 KiB
Python
580 lines
20 KiB
Python
"""
|
|
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()
|