"""Unit tests for Configuration Manager.""" import pytest import yaml from pathlib import Path from vlm.config import Config, load_config, create_default_config, validate_config class TestConfig: """Test Config dataclass.""" def test_config_creation_with_defaults(self): """Test creating Config with default values.""" config = Config(library_root=Path("/mnt/nas/videos")) assert config.library_root == Path("/mnt/nas/videos") assert ".mp4" in config.video_extensions assert ".mkv" in config.video_extensions assert config.movie_template == "movie/{title} ({year})/" assert config.series_template == "series/{title}/Season {season:02d}/" assert config.log_level == "INFO" assert config.quarantine_dir == ".quarantine" assert config.enrichment_providers == ["tmdb"] def test_config_creation_with_custom_values(self): """Test creating Config with custom values.""" config = Config( library_root=Path("/custom/path"), video_extensions=[".mp4", ".avi"], movie_template="movies/{title}-{year}/", log_level="DEBUG" ) assert config.library_root == Path("/custom/path") assert config.video_extensions == [".mp4", ".avi"] assert config.movie_template == "movies/{title}-{year}/" assert config.log_level == "DEBUG" def test_config_default_categories(self): """Test Config has default categories.""" config = Config(library_root=Path("/test")) assert config.categories == { "movie": ["movie", "movies"], "series": ["series", "tv", "shows"], "anime": ["anime"] } def test_config_custom_categories(self): """Test Config with custom category mappings.""" config = Config( library_root=Path("/test"), categories={ "movie": ["movie", "movies", "films"], "series": ["series", "tv"], "anime": ["anime"] } ) assert "movies" in config.categories["movie"] assert "tv" in config.categories["series"] class TestLoadConfig: """Test load_config function.""" def test_load_config_success(self, tmp_path): """Test loading valid configuration file.""" config_file = tmp_path / "config.yaml" config_data = { 'library_root': '/mnt/nas/videos', 'video_extensions': ['.mp4', '.mkv', '.avi'], 'templates': { 'movie_dir': 'movie/{title} ({year})/', 'series_dir': 'series/{title}/Season {season:02d}/', 'movie_filename': '{title} ({year}){ext}', 'series_filename': 'S{season:02d}E{episode:02d}{ext}' }, 'quarantine_dir': '.quarantine', 'log_level': 'DEBUG' } with open(config_file, 'w') as f: yaml.dump(config_data, f) config = load_config(config_file) assert config.library_root == Path('/mnt/nas/videos') assert config.video_extensions == ['.mp4', '.mkv', '.avi'] assert config.movie_template == 'movie/{title} ({year})/' assert config.series_template == 'series/{title}/Season {season:02d}/' assert config.log_level == 'DEBUG' assert config.quarantine_dir == '.quarantine' def test_load_config_with_home_directory(self, tmp_path): """Test loading config with ~ in library_root.""" config_file = tmp_path / "config.yaml" config_data = { 'library_root': '~/Videos', 'video_extensions': ['.mp4'] } with open(config_file, 'w') as f: yaml.dump(config_data, f) config = load_config(config_file) # Should expand ~ to home directory assert config.library_root == Path.home() / "Videos" def test_load_config_missing_file(self, tmp_path): """Test loading non-existent configuration file.""" config_file = tmp_path / "nonexistent.yaml" with pytest.raises(FileNotFoundError): load_config(config_file) def test_load_config_invalid_yaml(self, tmp_path): """Test loading configuration with invalid YAML syntax.""" config_file = tmp_path / "config.yaml" with open(config_file, 'w') as f: f.write("invalid: yaml: syntax: [unclosed") with pytest.raises(yaml.YAMLError): load_config(config_file) def test_load_config_missing_library_root(self, tmp_path): """Test loading configuration without library_root.""" config_file = tmp_path / "config.yaml" config_data = { 'video_extensions': ['.mp4'] } with open(config_file, 'w') as f: yaml.dump(config_data, f) with pytest.raises(ValueError, match="library_root"): load_config(config_file) def test_load_config_empty_file(self, tmp_path): """Test loading empty configuration file.""" config_file = tmp_path / "config.yaml" config_file.write_text("") with pytest.raises(ValueError, match="library_root"): load_config(config_file) def test_load_config_with_defaults(self, tmp_path): """Test loading config with minimal settings uses defaults.""" config_file = tmp_path / "config.yaml" config_data = { 'library_root': '/mnt/nas/videos' } with open(config_file, 'w') as f: yaml.dump(config_data, f) config = load_config(config_file) # Should use default values for missing fields assert config.library_root == Path('/mnt/nas/videos') assert ".mp4" in config.video_extensions assert config.movie_template == "movie/{title} ({year})/" assert config.log_level == "INFO" def test_load_config_with_custom_categories(self, tmp_path): """Test loading config with custom categories.""" config_file = tmp_path / "config.yaml" config_file.write_text(""" library_root: /test/library categories: movie: [movie, movies, films] series: [series, tv, shows] anime: [anime] """) config = load_config(config_file) assert config.categories["movie"] == ["movie", "movies", "films"] assert config.categories["series"] == ["series", "tv", "shows"] def test_load_config_with_tmdb_settings(self, tmp_path): """Test loading TMDB auth and query preferences from config.""" config_file = tmp_path / "config.yaml" config_file.write_text(""" library_root: /test/library enrichment: api_keys: tmdb_bearer: bearer-token tmdb: language: zh-TW region: TW include_adult: false """) config = load_config(config_file) assert config.tmdb_bearer_token == "bearer-token" assert config.tmdb_language == "zh-TW" assert config.tmdb_region == "TW" assert config.tmdb_include_adult is False def test_load_config_with_enrich_alias(self, tmp_path): """Test loading enrichment settings from `enrich` alias.""" config_file = tmp_path / "config.yaml" config_file.write_text(""" library_root: /test/library enrich: enabled: false providers: [tmdb] api_keys: tmdb_bearer: alias-bearer-token tmdb: language: en-US """) config = load_config(config_file) assert config.enrichment_enabled is False assert config.enrichment_providers == ["tmdb"] assert config.tmdb_bearer_token == "alias-bearer-token" assert config.tmdb_language == "en-US" class TestCreateDefaultConfig: """Test create_default_config function.""" def test_create_default_config(self, tmp_path): """Test creating default configuration file.""" config_file = tmp_path / "config.yaml" config = create_default_config(config_file) # Check returned config object assert config.library_root == Path.home() / "Videos" assert ".mp4" in config.video_extensions assert ".mkv" in config.video_extensions assert config.movie_template == "movie/{title} ({year})/" assert config.series_template == "series/{title}/Season {season:02d}/" assert config.log_level == "INFO" assert config.quarantine_dir == ".quarantine" # Check file was created assert config_file.exists() # Check file content with open(config_file, 'r') as f: data = yaml.safe_load(f) assert 'library_root' in data assert 'video_extensions' in data assert 'templates' in data assert 'log_level' in data assert 'quarantine_dir' in data assert 'enrichment' in data assert 'enrich' in data def test_create_default_config_creates_parent_dirs(self, tmp_path): """Test that create_default_config creates parent directories.""" config_file = tmp_path / "subdir" / "config.yaml" config = create_default_config(config_file) assert config_file.exists() assert config_file.parent.exists() def test_create_default_config_is_loadable(self, tmp_path): """Test that created default config can be loaded.""" config_file = tmp_path / "config.yaml" created_config = create_default_config(config_file) loaded_config = load_config(config_file) # Configs should be equivalent assert loaded_config.library_root == created_config.library_root assert loaded_config.video_extensions == created_config.video_extensions assert loaded_config.movie_template == created_config.movie_template assert loaded_config.log_level == created_config.log_level class TestValidateConfig: """Test validate_config function.""" def test_validate_valid_config(self): """Test validating a valid configuration.""" config = Config(library_root=Path("/mnt/nas/videos")) errors = validate_config(config) assert errors == [] def test_validate_empty_library_root(self): """Test validating config with empty library_root.""" config = Config(library_root=Path("")) errors = validate_config(config) assert len(errors) > 0 assert any("library_root" in err for err in errors) def test_validate_empty_video_extensions(self): """Test validating config with empty video_extensions.""" config = Config( library_root=Path("/mnt/nas/videos"), video_extensions=[] ) errors = validate_config(config) assert len(errors) > 0 assert any("video_extensions" in err for err in errors) def test_validate_invalid_video_extension_format(self): """Test validating config with invalid video extension format.""" config = Config( library_root=Path("/mnt/nas/videos"), video_extensions=["mp4", ".mkv"] # Missing dot on first one ) errors = validate_config(config) assert len(errors) > 0 assert any("must start with '.'" in err for err in errors) def test_validate_empty_templates(self): """Test validating config with empty templates.""" config = Config( library_root=Path("/mnt/nas/videos"), movie_template="", series_template="" ) errors = validate_config(config) assert len(errors) >= 2 assert any("movie_template" in err for err in errors) assert any("series_template" in err for err in errors) def test_validate_invalid_log_level(self): """Test validating config with invalid log level.""" config = Config( library_root=Path("/mnt/nas/videos"), log_level="INVALID" ) errors = validate_config(config) assert len(errors) > 0 assert any("log_level" in err for err in errors) def test_validate_valid_log_levels(self): """Test validating config with all valid log levels.""" valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] for level in valid_levels: config = Config( library_root=Path("/mnt/nas/videos"), log_level=level ) errors = validate_config(config) assert errors == [], f"Log level {level} should be valid" def test_validate_absolute_quarantine_dir(self): """Test validating config with absolute quarantine_dir.""" config = Config( library_root=Path("/mnt/nas/videos"), quarantine_dir="/absolute/path" ) errors = validate_config(config) assert len(errors) > 0 assert any("must be relative" in err for err in errors) def test_validate_empty_quarantine_dir(self): """Test validating config with empty quarantine_dir.""" config = Config( library_root=Path("/mnt/nas/videos"), quarantine_dir="" ) errors = validate_config(config) assert len(errors) > 0 assert any("quarantine_dir" in err for err in errors) def test_validate_multiple_errors(self): """Test validating config with multiple errors.""" config = Config( library_root=Path(""), video_extensions=[], movie_template="", log_level="INVALID" ) errors = validate_config(config) # Should have multiple errors assert len(errors) >= 4 def test_validate_empty_categories(self): """Test validating config with empty categories.""" config = Config(library_root=Path("/test"), categories={}) errors = validate_config(config) assert any("categories" in e and "empty" in e for e in errors) def test_validate_missing_required_category(self): """Test validating config with missing required categories.""" config = Config( library_root=Path("/test"), categories={"movie": ["movie"]} # Missing series, anime ) errors = validate_config(config) assert any("series" in e or "anime" in e for e in errors) def test_validate_duplicate_directory_names(self): """Test validating config with duplicate directory names.""" config = Config( library_root=Path("/test"), categories={ "movie": ["movie", "videos"], "series": ["series", "videos"], # Duplicate "anime": ["anime"] } ) errors = validate_config(config) assert any("Duplicate" in e and "videos" in e for e in errors) def test_validate_case_insensitive_duplicates(self): """Test validating config with case-insensitive duplicates.""" config = Config( library_root=Path("/test"), categories={ "movie": ["Movie"], "series": ["movie"], # Case-insensitive duplicate "anime": ["anime"] } ) errors = validate_config(config) assert any("Duplicate" in e for e in errors) def test_validate_valid_custom_categories(self): """Test validating config with valid custom categories.""" config = Config( library_root=Path("/test"), categories={ "movie": ["movie", "movies"], "series": ["series", "tv"], "anime": ["anime"] } ) errors = validate_config(config) assert errors == [] def test_validate_categories_not_dict(self): """Test validating config with categories not a dict.""" config = Config( library_root=Path("/test"), categories=["movie", "series"] # Wrong type ) errors = validate_config(config) assert any("must be a dictionary" in e for e in errors) def test_validate_category_list_not_list(self): """Test validating config with category value not a list.""" config = Config( library_root=Path("/test"), categories={ "movie": "movie", # Should be a list "series": ["series"], "anime": ["anime"] } ) errors = validate_config(config) assert any("must be a list" in e for e in errors) def test_validate_empty_category_list(self): """Test validating config with empty category list.""" config = Config( library_root=Path("/test"), categories={ "movie": [], # Empty list "series": ["series"], "anime": ["anime"] } ) errors = validate_config(config) assert any("cannot be empty" in e for e in errors) def test_validate_rejects_unsupported_enrichment_provider(self): """Test validating config with unsupported enrichment provider.""" config = Config( library_root=Path("/test"), enrichment_providers=["tmdb", "douban"], ) errors = validate_config(config) assert any("unsupported providers" in e for e in errors) def test_validate_category_list_with_non_string(self): """Test validating config with non-string in category list.""" config = Config( library_root=Path("/test"), categories={ "movie": ["movie", 123], # Non-string "series": ["series"], "anime": ["anime"] } ) errors = validate_config(config) assert any("must contain strings" in e for e in errors) def test_validate_category_list_with_empty_string(self): """Test validating config with empty string in category list.""" config = Config( library_root=Path("/test"), categories={ "movie": ["movie", ""], # Empty string "series": ["series"], "anime": ["anime"] } ) errors = validate_config(config) assert any("empty directory name" in e for e in errors) class TestConfigIntegration: """Integration tests for configuration workflow.""" def test_missing_config_workflow(self, tmp_path): """Test workflow: missing config -> create default -> load.""" config_file = tmp_path / "config.yaml" # Config doesn't exist assert not config_file.exists() # Try to load, should raise FileNotFoundError with pytest.raises(FileNotFoundError): load_config(config_file) # Create default config default_config = create_default_config(config_file) # Now file exists assert config_file.exists() # Load the created config loaded_config = load_config(config_file) # Should match default assert loaded_config.library_root == default_config.library_root assert loaded_config.video_extensions == default_config.video_extensions def test_invalid_yaml_workflow(self, tmp_path): """Test workflow: invalid YAML -> report error -> use defaults.""" config_file = tmp_path / "config.yaml" # Create invalid YAML with open(config_file, 'w') as f: f.write("invalid: yaml: [unclosed") # Try to load, should raise YAMLError with pytest.raises(yaml.YAMLError): load_config(config_file) # In real usage, caller would catch this and create default default_config = create_default_config(config_file) # Now should be loadable loaded_config = load_config(config_file) assert loaded_config.library_root == default_config.library_root def test_validation_workflow(self, tmp_path): """Test workflow: load config -> validate -> report errors.""" config_file = tmp_path / "config.yaml" # Create config with some invalid values config_data = { 'library_root': '/mnt/nas/videos', 'video_extensions': ['mp4', '.mkv'], # First one missing dot 'log_level': 'INVALID' } with open(config_file, 'w') as f: yaml.dump(config_data, f) # Load config config = load_config(config_file) # Validate errors = validate_config(config) # Should have errors assert len(errors) > 0 assert any("must start with '.'" in err for err in errors) assert any("log_level" in err for err in errors)