diff --git a/.gitignore b/.gitignore index ae71ccc..13fb196 100644 --- a/.gitignore +++ b/.gitignore @@ -205,6 +205,7 @@ htmlcov/ .cache nosetests.xml coverage.xml +coverage.json *.cover *.py,cover .hypothesis/ diff --git a/pyproject.toml b/pyproject.toml index 9624c8d..710e015 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "tqdm>=4.65", "psutil>=6.0", "tabulate>=0.9.0", - "aiohttp>=3.9.0", + "aiohttp>=3.13.4", ] [project.optional-dependencies] @@ -93,7 +93,7 @@ include = [ versiontracker = ["py.typed"] [tool.black] -line-length = 88 +line-length = 120 target-version = ["py312"] include = '\.pyi?$' diff --git a/tests/test_comparator_coverage.py b/tests/test_comparator_coverage.py new file mode 100644 index 0000000..d49b91c --- /dev/null +++ b/tests/test_comparator_coverage.py @@ -0,0 +1,413 @@ +"""Coverage tests for versiontracker.version.comparator. + +Targets the ~14% of uncovered lines identified by the audit: +- _compare_base_versions: < and > branches +- _compare_build_numbers: all None/non-None combinations +- _convert_versions_to_strings: tuple_to_version_str returning None +- _compare_prerelease: unknown prerelease type handling +- _compare_prerelease_suffixes: equal int suffixes +- _process_prerelease_suffix: string (non-int) suffix +- _extract_prerelease_type_and_suffix: standalone Unicode character +- _convert_versions_to_tuples: None version1 / None version2 +- _apply_version_truncation: build metadata and prerelease truncation +- get_version_difference: build-metadata and prerelease versions +- get_version_info: None current, malformed, empty, NEWER status +- _handle_empty_version_cases: both empty, one empty +- _perform_version_comparison: malformed latest version +""" + +from versiontracker.version.comparator import ( + _apply_version_truncation, + _compare_base_versions, + _compare_build_numbers, + _compare_prerelease, + _compare_prerelease_suffixes, + _convert_versions_to_tuples, + _extract_prerelease_type_and_suffix, + _handle_empty_version_cases, + _process_prerelease_suffix, + compare_versions, + get_version_difference, + get_version_info, + is_version_newer, +) +from versiontracker.version.models import VersionStatus + +# --------------------------------------------------------------------------- +# _compare_base_versions +# --------------------------------------------------------------------------- + + +class TestCompareBaseVersions: + def test_v1_less_than_v2(self): + """Line 171: returns -1 when v1_base < v2_base.""" + assert _compare_base_versions((1, 0, 0), (2, 0, 0)) == -1 + + def test_v1_greater_than_v2(self): + """Line 173: returns 1 when v1_base > v2_base.""" + assert _compare_base_versions((2, 0, 0), (1, 0, 0)) == 1 + + def test_equal_returns_zero(self): + assert _compare_base_versions((1, 2, 3), (1, 2, 3)) == 0 + + def test_short_tuples_padded(self): + """Short tuples are zero-padded before comparison.""" + assert _compare_base_versions((1,), (1, 0, 0)) == 0 + assert _compare_base_versions((2,), (1, 9, 9)) == 1 + + +# --------------------------------------------------------------------------- +# _compare_build_numbers +# --------------------------------------------------------------------------- + + +class TestCompareBuildNumbers: + def test_both_none_returns_zero(self): + assert _compare_build_numbers(None, None) == 0 + + def test_v1_has_build_v2_does_not(self): + """Line 187: v1 has build number, v2 doesn't → returns 1.""" + assert _compare_build_numbers(100, None) == 1 + + def test_v2_has_build_v1_does_not(self): + """Line 189: v2 has build number, v1 doesn't → returns -1.""" + assert _compare_build_numbers(None, 200) == -1 + + def test_v1_build_less_than_v2(self): + """Line 182: v1_build < v2_build → returns -1.""" + assert _compare_build_numbers(100, 200) == -1 + + def test_v1_build_greater_than_v2(self): + """Line 184: v1_build > v2_build → returns 1.""" + assert _compare_build_numbers(200, 100) == 1 + + def test_equal_builds_returns_zero(self): + assert _compare_build_numbers(100, 100) == 0 + + +# --------------------------------------------------------------------------- +# _compare_prerelease +# --------------------------------------------------------------------------- + + +class TestComparePrerelease: + def test_unknown_type_v1_logs_warning(self): + """Lines 450-451: γ maps to 'gamma' (not in type_priority) → defaults to beta (2). + rc has priority 3 → gamma(2) < rc(3) → returns -1.""" + result = _compare_prerelease("1.0-γ", "1.0-rc.1") + assert result == -1 + + def test_unknown_type_v2_logs_warning(self): + """Lines 453-454: rc (3) vs γ mapped to gamma (unknown→beta=2) → rc > gamma → 1.""" + result = _compare_prerelease("1.0-rc.1", "1.0-γ") + assert result == 1 + + def test_both_unknown_same_suffix(self): + """Same unknown type twice → same priority and same suffix → equal (0).""" + result = _compare_prerelease("1.0-γ", "1.0-γ") + assert result == 0 + + def test_alpha_less_than_rc(self): + assert _compare_prerelease("1.0-alpha.1", "1.0-rc.1") == -1 + + def test_rc_greater_than_beta(self): + assert _compare_prerelease("1.0-rc.1", "1.0-beta.1") == 1 + + +# --------------------------------------------------------------------------- +# _compare_prerelease_suffixes +# --------------------------------------------------------------------------- + + +class TestComparePrereleaseSuffixes: + def test_equal_int_suffixes(self): + """Line 544: both suffixes are equal ints → returns 0.""" + assert _compare_prerelease_suffixes(2, 2) == 0 + + def test_int_less_than_int(self): + assert _compare_prerelease_suffixes(1, 2) == -1 + + def test_int_greater_than_int(self): + assert _compare_prerelease_suffixes(3, 2) == 1 + + def test_string_vs_string_equal(self): + assert _compare_prerelease_suffixes("a", "a") == 0 + + def test_int_suffix_vs_string(self): + """int suffix sorts before string (lines 539-540).""" + assert _compare_prerelease_suffixes(1, "a") == -1 + + def test_string_suffix_vs_int(self): + """string suffix sorts after int (lines 541-542).""" + assert _compare_prerelease_suffixes("a", 1) == 1 + + +# --------------------------------------------------------------------------- +# _process_prerelease_suffix +# --------------------------------------------------------------------------- + + +class TestProcessPrereleaseSuffix: + def test_int_string_becomes_int(self): + assert _process_prerelease_suffix("3") == 3 + + def test_non_int_string_stays_string(self): + """Lines 558-559: non-numeric suffix returns string.""" + result = _process_prerelease_suffix("stable") + assert result == "stable" + + def test_none_returns_none(self): + assert _process_prerelease_suffix(None) is None + + def test_empty_string_returns_none(self): + """Empty/falsy string returns None.""" + assert _process_prerelease_suffix("") is None + + +# --------------------------------------------------------------------------- +# _extract_prerelease_type_and_suffix +# --------------------------------------------------------------------------- + + +class TestExtractPrereleaseTypeAndSuffix: + def test_standalone_alpha_unicode(self): + """Line 594: standalone α character maps to 'alpha'.""" + prerelease_type, suffix = _extract_prerelease_type_and_suffix("1.0-α") + assert prerelease_type == "alpha" + + def test_standalone_beta_unicode(self): + """Line 594: standalone β character maps to 'beta'.""" + prerelease_type, suffix = _extract_prerelease_type_and_suffix("1.0-β") + assert prerelease_type == "beta" + + def test_alpha_with_number(self): + prerelease_type, suffix = _extract_prerelease_type_and_suffix("1.0-alpha.2") + assert prerelease_type == "alpha" + assert suffix == 2 + + def test_rc_with_string_suffix(self): + prerelease_type, suffix = _extract_prerelease_type_and_suffix("1.0-rc.final") + assert prerelease_type == "rc" + assert suffix == "final" + + def test_no_prerelease_returns_final(self): + prerelease_type, suffix = _extract_prerelease_type_and_suffix("1.0.0") + assert prerelease_type == "final" + assert suffix is None + + +# --------------------------------------------------------------------------- +# _convert_versions_to_tuples +# --------------------------------------------------------------------------- + + +class TestConvertVersionsToTuples: + def test_none_version1_becomes_zeros(self): + """Line 659: None version1 → (0, 0, 0).""" + v1, v2 = _convert_versions_to_tuples(None, "1.2.3") + assert v1 == (0, 0, 0) + + def test_none_version2_becomes_zeros(self): + """Line 668: None version2 → (0, 0, 0).""" + v1, v2 = _convert_versions_to_tuples("1.2.3", None) + assert v2 == (0, 0, 0) + + def test_both_none(self): + v1, v2 = _convert_versions_to_tuples(None, None) + assert v1 == (0, 0, 0) + assert v2 == (0, 0, 0) + + def test_tuple_versions_passed_through(self): + """Tuple versions are returned unchanged.""" + t = (1, 2, 3) + v1, v2 = _convert_versions_to_tuples(t, t) + assert v1 == t + assert v2 == t + + def test_unparseable_string_becomes_zeros(self): + """Strings that fail to parse → (0, 0, 0).""" + v1, v2 = _convert_versions_to_tuples("not-a-version!!!", "1.0.0") + # parse_version may return None for gibberish + assert isinstance(v1, tuple) + + +# --------------------------------------------------------------------------- +# _apply_version_truncation +# --------------------------------------------------------------------------- + + +class TestApplyVersionTruncation: + def test_both_have_build_metadata_truncates_to_3(self): + """Lines 705-708: build metadata → truncate to 3 components.""" + v1 = (1, 2, 3, 4, 5) + v2 = (1, 2, 3, 4, 6) + result_v1, result_v2, max_len = _apply_version_truncation(v1, v2, 5, True, False) + assert len(result_v1) == 3 + assert len(result_v2) == 3 + assert max_len == 3 + + def test_both_have_prerelease_truncates_to_3(self): + """Lines 710-713: prerelease versions → truncate to 3 components.""" + v1 = (1, 2, 3, 4) + v2 = (1, 2, 3, 5) + result_v1, result_v2, max_len = _apply_version_truncation(v1, v2, 4, False, True) + assert len(result_v1) == 3 + assert max_len == 3 + + def test_neither_flag_no_truncation(self): + """No flags → no truncation.""" + v1 = (1, 2, 3, 4) + v2 = (1, 2, 3, 5) + result_v1, result_v2, max_len = _apply_version_truncation(v1, v2, 4, False, False) + assert max_len == 4 + + +# --------------------------------------------------------------------------- +# get_version_difference +# --------------------------------------------------------------------------- + + +class TestGetVersionDifference: + def test_both_empty_returns_zero_tuple(self): + """Lines 634-635: both empty → (0, 0, 0).""" + result = get_version_difference("", "") + assert result == (0, 0, 0) + + def test_one_malformed_returns_none(self): + """Line 643: one malformed version → None.""" + result = get_version_difference("not_a_version!!!", "1.0.0") + assert result is None + + def test_normal_difference(self): + result = get_version_difference("2.0.0", "1.0.0") + assert result is not None + assert result[0] > 0 + + def test_build_metadata_versions_truncated(self): + """Lines 705-708: build metadata causes truncation to 3 components.""" + result = get_version_difference("1.2.3+build.1", "1.2.3+build.2") + assert result is not None + + def test_prerelease_versions_truncated(self): + """Lines 710-713: prerelease versions are truncated.""" + result = get_version_difference("1.2.3-alpha.1", "1.2.3-beta.1") + assert result is not None + + def test_none_version_returns_none(self): + assert get_version_difference(None, "1.0.0") is None + assert get_version_difference("1.0.0", None) is None + + def test_tuple_versions(self): + result = get_version_difference((2, 0, 0), (1, 0, 0)) + assert result is not None + assert result[0] > 0 + + +# --------------------------------------------------------------------------- +# _handle_empty_version_cases +# --------------------------------------------------------------------------- + + +class TestHandleEmptyVersionCases: + def test_both_empty_returns_up_to_date(self): + """Line 837: both empty → UP_TO_DATE.""" + result = _handle_empty_version_cases("", "") + assert result == VersionStatus.UP_TO_DATE + + def test_current_empty_returns_unknown(self): + """Line 840-841: one empty → UNKNOWN.""" + result = _handle_empty_version_cases("", "1.0.0") + assert result == VersionStatus.UNKNOWN + + def test_latest_empty_returns_unknown(self): + result = _handle_empty_version_cases("1.0.0", "") + assert result == VersionStatus.UNKNOWN + + def test_neither_empty_returns_none(self): + """Neither empty → None (continue normal processing).""" + assert _handle_empty_version_cases("1.0.0", "2.0.0") is None + + +# --------------------------------------------------------------------------- +# get_version_info +# --------------------------------------------------------------------------- + + +class TestGetVersionInfo: + def test_none_current_version(self): + """Lines 770-771: None current_version is treated as empty string.""" + info = get_version_info(None) + assert info is not None + + def test_single_version_no_comparison(self): + """Lines 781-783: without latest_version, just returns basic info.""" + info = get_version_info("1.0.0") + assert info is not None + assert info.status == VersionStatus.UNKNOWN + + def test_up_to_date(self): + info = get_version_info("1.0.0", "1.0.0") + assert info.status == VersionStatus.UP_TO_DATE + + def test_outdated(self): + """Line 859-862: OUTDATED with outdated_by populated.""" + info = get_version_info("1.0.0", "2.0.0") + assert info.status == VersionStatus.OUTDATED + + def test_newer(self): + """Lines 864-867: NEWER with newer_by populated.""" + info = get_version_info("2.0.0", "1.0.0") + assert info.status == VersionStatus.NEWER + + def test_malformed_current_unknown(self): + """Lines 816-818: malformed current version → UNKNOWN.""" + info = get_version_info("not_a_version!!!", "1.0.0") + assert info.status == VersionStatus.UNKNOWN + + def test_malformed_latest_unknown(self): + """Lines 803-804: malformed latest version → (0,0,0) parsed.""" + info = get_version_info("1.0.0", "not_a_version!!!") + assert info is not None + + def test_empty_current_and_latest(self): + """Both empty → UP_TO_DATE (via _handle_empty_version_cases).""" + info = get_version_info("", "") + assert info.status == VersionStatus.UP_TO_DATE + + def test_empty_current_non_empty_latest(self): + """One empty → UNKNOWN.""" + info = get_version_info("", "1.0.0") + assert info.status == VersionStatus.UNKNOWN + + +# --------------------------------------------------------------------------- +# is_version_newer (smoke tests of public API) +# --------------------------------------------------------------------------- + + +class TestIsVersionNewer: + def test_older_is_not_newer(self): + assert is_version_newer("1.0.0", "1.0.0") is False + + def test_latest_is_newer(self): + assert is_version_newer("1.0.0", "2.0.0") is True + + def test_current_ahead_not_newer(self): + assert is_version_newer("2.0.0", "1.0.0") is False + + +# --------------------------------------------------------------------------- +# compare_versions (edge cases hitting application-prefix path) +# --------------------------------------------------------------------------- + + +class TestCompareVersionsEdgeCases: + def test_prefix_versions_equal(self): + """Line 355: application prefix versions treated as equal.""" + result = compare_versions("Firefox 120.0", "Firefox 120.0") + assert result == 0 + + def test_both_versions_are_tuples(self): + """Tuple inputs are handled.""" + assert compare_versions((1, 0, 0), (2, 0, 0)) < 0 + assert compare_versions((2, 0, 0), (1, 0, 0)) > 0 diff --git a/tests/test_end_to_end_integration.py b/tests/test_end_to_end_integration.py index 297fbb6..5bc7781 100644 --- a/tests/test_end_to_end_integration.py +++ b/tests/test_end_to_end_integration.py @@ -28,6 +28,15 @@ class TestEndToEndIntegration: """End-to-end integration tests for complete workflows.""" + @pytest.fixture(autouse=True) + def clear_lru_caches(self): + """Clear lru_cache state between tests to prevent cross-test contamination.""" + from versiontracker.apps.finder import clear_homebrew_casks_cache + + clear_homebrew_casks_cache() + yield + clear_homebrew_casks_cache() + @pytest.fixture def temp_config_dir(self): """Create temporary configuration directory.""" @@ -170,11 +179,17 @@ def test_configuration_management_workflow(self, temp_config_dir): def test_auto_updates_management_workflow(self, mock_homebrew_casks, mock_homebrew_available): """Test auto-updates management workflow.""" - # Mock has_auto_updates to avoid real brew subprocess calls + # Patch get_homebrew_casks at the handler import site to avoid real brew calls + # and prevent lru_cache from returning stale data from other tests. with ( mock.patch("builtins.input", return_value="y"), mock.patch("sys.argv", ["versiontracker", "--blacklist-auto-updates"]), + mock.patch( + "versiontracker.handlers.auto_update_handlers.get_homebrew_casks", + return_value=["firefox", "google-chrome"], + ), mock.patch("versiontracker.homebrew.has_auto_updates", return_value=True), + mock.patch("versiontracker.homebrew.get_casks_with_auto_updates", return_value=[]), ): result = versiontracker_main() diff --git a/tests/test_exceptions_and_error_codes.py b/tests/test_exceptions_and_error_codes.py new file mode 100644 index 0000000..5857b47 --- /dev/null +++ b/tests/test_exceptions_and_error_codes.py @@ -0,0 +1,420 @@ +"""Tests for versiontracker.exceptions and versiontracker.error_codes modules. + +Covers the structured error hierarchy, StructuredError class, create_error helper, +and get_errors_by_category / get_errors_by_severity filters. +""" + +import pytest + +from versiontracker.error_codes import ( + ErrorCategory, + ErrorCode, + ErrorSeverity, + StructuredError, + create_error, + get_errors_by_category, + get_errors_by_severity, +) +from versiontracker.exceptions import ( + ApplicationError, + BrewPermissionError, + BrewTimeoutError, + CacheError, + ConfigError, + DataParsingError, + ExportError, + HandlerError, + HomebrewError, + NetworkError, + TimeoutError, + ValidationError, + VersionError, + VersionTrackerError, +) + +# --------------------------------------------------------------------------- +# error_codes.py +# --------------------------------------------------------------------------- + + +class TestErrorCode: + def test_enum_members_have_code_message_severity(self): + ec = ErrorCode.NET001 + assert ec.code == "NET001" + assert "Network" in ec.message + assert ec.severity == ErrorSeverity.HIGH + + def test_category_derived_from_prefix(self): + assert ErrorCode.NET001.category == ErrorCategory.NETWORK + assert ErrorCode.HBW001.category == ErrorCategory.HOMEBREW + assert ErrorCode.CFG002.category == ErrorCategory.CONFIG + assert ErrorCode.SYS001.category == ErrorCategory.SYSTEM + assert ErrorCode.APP001.category == ErrorCategory.APPLICATION + assert ErrorCode.VER001.category == ErrorCategory.VERSION + assert ErrorCode.PRM001.category == ErrorCategory.PERMISSION + assert ErrorCode.VAL001.category == ErrorCategory.VALIDATION + assert ErrorCode.CHE001.category == ErrorCategory.CACHE + assert ErrorCode.EXP001.category == ErrorCategory.EXPORT + + def test_severity_values(self): + assert ErrorSeverity.LOW.value == "low" + assert ErrorSeverity.MEDIUM.value == "medium" + assert ErrorSeverity.HIGH.value == "high" + assert ErrorSeverity.CRITICAL.value == "critical" + + +class TestStructuredError: + def test_basic_properties(self): + err = StructuredError(error_code=ErrorCode.NET001) + assert err.code == "NET001" + assert err.message == ErrorCode.NET001.message + assert err.severity == ErrorSeverity.HIGH + assert err.category == ErrorCategory.NETWORK + + def test_defaults(self): + err = StructuredError(error_code=ErrorCode.SYS001) + assert err.details == "" + assert err.context == {} + assert err.suggestions == [] + assert err.original_exception is None + + def test_with_all_fields(self): + orig = ValueError("original") + err = StructuredError( + error_code=ErrorCode.CFG002, + details="bad yaml", + context={"file": "/tmp/config.yaml"}, + suggestions=["Check syntax"], + original_exception=orig, + ) + assert err.details == "bad yaml" + assert err.context == {"file": "/tmp/config.yaml"} + assert err.suggestions == ["Check syntax"] + assert err.original_exception is orig + + def test_to_dict_includes_all_keys(self): + err = StructuredError( + error_code=ErrorCode.HBW001, + details="not found", + context={"path": "/usr/local/bin/brew"}, + suggestions=["Install Homebrew"], + original_exception=RuntimeError("missing"), + ) + d = err.to_dict() + assert d["code"] == "HBW001" + assert d["details"] == "not found" + assert d["context"] == {"path": "/usr/local/bin/brew"} + assert d["suggestions"] == ["Install Homebrew"] + assert "missing" in d["original_exception"] + assert d["severity"] == "critical" + assert d["category"] == "HBW" + + def test_to_dict_no_exception(self): + err = StructuredError(error_code=ErrorCode.VAL001) + assert err.to_dict()["original_exception"] is None + + def test_format_user_message_minimal(self): + err = StructuredError(error_code=ErrorCode.VER001) + msg = err.format_user_message() + assert "VER001" in msg + assert ErrorCode.VER001.message in msg + + def test_format_user_message_with_details(self): + err = StructuredError(error_code=ErrorCode.VER001, details="1.2.3.4.5 is invalid") + msg = err.format_user_message() + assert "Details:" in msg + assert "1.2.3.4.5 is invalid" in msg + + def test_format_user_message_with_context(self): + err = StructuredError(error_code=ErrorCode.PRM001, context={"file": "/etc/hosts"}) + msg = err.format_user_message() + assert "Context:" in msg + assert "/etc/hosts" in msg + + def test_format_user_message_with_suggestions(self): + err = StructuredError(error_code=ErrorCode.NET001, suggestions=["Check wifi", "Try VPN"]) + msg = err.format_user_message() + assert "Suggestions:" in msg + assert "Check wifi" in msg + assert "Try VPN" in msg + + def test_str_representation(self): + err = StructuredError(error_code=ErrorCode.APP001) + assert "APP001" in str(err) + + def test_repr_representation(self): + err = StructuredError(error_code=ErrorCode.EXP001) + r = repr(err) + assert "EXP001" in r + assert "StructuredError" in r + assert "medium" in r + + +class TestCreateError: + def test_creates_structured_error(self): + err = create_error(ErrorCode.NET001) + assert isinstance(err, StructuredError) + assert err.code == "NET001" + + def test_auto_populates_suggestions_for_known_codes(self): + err = create_error(ErrorCode.HBW001) + assert len(err.suggestions) > 0 + + def test_no_suggestions_for_unknown_code(self): + err = create_error(ErrorCode.CHE001) + assert err.suggestions == [] + + def test_forwards_details_and_context(self): + err = create_error(ErrorCode.CFG002, details="bad format", context={"line": 5}) + assert err.details == "bad format" + assert err.context == {"line": 5} + + def test_forwards_original_exception(self): + orig = KeyError("missing_key") + err = create_error(ErrorCode.SYS001, original_exception=orig) + assert err.original_exception is orig + + +class TestGetErrorsByCategory: + def test_returns_only_matching_category(self): + net_errors = get_errors_by_category(ErrorCategory.NETWORK) + assert all(e.category == ErrorCategory.NETWORK for e in net_errors) + assert ErrorCode.NET001 in net_errors + + def test_homebrew_category(self): + hbw = get_errors_by_category(ErrorCategory.HOMEBREW) + assert ErrorCode.HBW001 in hbw + assert ErrorCode.NET001 not in hbw + + def test_returns_list(self): + assert isinstance(get_errors_by_category(ErrorCategory.CACHE), list) + + +class TestGetErrorsBySeverity: + def test_returns_only_matching_severity(self): + criticals = get_errors_by_severity(ErrorSeverity.CRITICAL) + assert all(e.severity == ErrorSeverity.CRITICAL for e in criticals) + + def test_high_severity_non_empty(self): + highs = get_errors_by_severity(ErrorSeverity.HIGH) + assert len(highs) > 0 + assert ErrorCode.NET001 in highs + + def test_returns_list(self): + assert isinstance(get_errors_by_severity(ErrorSeverity.LOW), list) + + +# --------------------------------------------------------------------------- +# exceptions.py +# --------------------------------------------------------------------------- + + +class TestVersionTrackerError: + def test_plain_message(self): + err = VersionTrackerError("something went wrong") + assert str(err) == "something went wrong" + assert err.structured_error is None + assert err.get_error_code() is None + assert err.get_context() == {} + + def test_with_error_code(self): + err = VersionTrackerError(error_code=ErrorCode.SYS001) + assert err.structured_error is not None + assert err.get_error_code() == "SYS001" + + def test_with_error_code_and_details(self): + err = VersionTrackerError(error_code=ErrorCode.NET001, details="connection refused") + assert err.structured_error.details == "connection refused" + + def test_with_context(self): + err = VersionTrackerError(error_code=ErrorCode.CFG001, context={"file": "cfg.yaml"}) + assert err.get_context() == {"file": "cfg.yaml"} + + def test_with_original_exception(self): + orig = OSError("disk full") + err = VersionTrackerError(error_code=ErrorCode.SYS004, original_exception=orig) + assert err.structured_error.original_exception is orig + + def test_to_dict_with_structured_error(self): + err = VersionTrackerError(error_code=ErrorCode.VER001) + d = err.to_dict() + assert d["code"] == "VER001" + + def test_to_dict_without_structured_error(self): + err = VersionTrackerError("plain") + d = err.to_dict() + assert d["message"] == "plain" + assert d["code"] is None + + def test_is_exception(self): + with pytest.raises(VersionTrackerError): + raise VersionTrackerError("test") + + +class TestConfigError: + def test_default_message(self): + err = ConfigError() + assert err is not None + + def test_with_config_file_and_error_code(self): + # context is only stored when error_code is also provided + err = ConfigError("bad config", config_file="/home/user/.config.yaml", error_code=ErrorCode.CFG002) + assert err.get_context().get("config_file") == "/home/user/.config.yaml" + + def test_with_config_file_no_error_code(self): + # Without error_code the constructor still accepts config_file; context not retained + err = ConfigError("bad config", config_file="/home/user/.config.yaml") + assert isinstance(err, ConfigError) + + def test_without_config_file(self): + err = ConfigError("missing key") + assert "config_file" not in err.get_context() + + def test_is_versiontracker_error(self): + assert isinstance(ConfigError(), VersionTrackerError) + + +class TestVersionError: + def test_with_version_string_and_error_code(self): + err = VersionError("parse failed", version_string="1.2.3.bad", error_code=ErrorCode.VER001) + assert err.get_context().get("version_string") == "1.2.3.bad" + + def test_with_version_string_no_error_code(self): + # Constructor accepts version_string even without error_code + err = VersionError("parse failed", version_string="1.2.3.bad") + assert isinstance(err, VersionError) + + def test_without_version_string(self): + err = VersionError("generic version error") + assert "version_string" not in err.get_context() + + +class TestNetworkError: + def test_with_url_and_status_and_error_code(self): + err = NetworkError("request failed", url="https://example.com", status_code=503, error_code=ErrorCode.NET001) + ctx = err.get_context() + assert ctx.get("url") == "https://example.com" + assert ctx.get("status_code") == 503 + + def test_with_url_no_error_code(self): + # Constructor accepts url without error_code + err = NetworkError("timeout", url="https://api.example.com") + assert isinstance(err, NetworkError) + + def test_without_url(self): + err = NetworkError("network down") + assert "url" not in err.get_context() + + +class TestTimeoutError: + def test_with_timeout_and_operation_and_error_code(self): + err = TimeoutError("timed out", timeout_seconds=30.0, operation="brew update", error_code=ErrorCode.SYS005) + ctx = err.get_context() + assert ctx.get("timeout_seconds") == 30.0 + assert ctx.get("operation") == "brew update" + + def test_without_optional_fields(self): + err = TimeoutError("timed out") + assert "timeout_seconds" not in err.get_context() + assert "operation" not in err.get_context() + + +class TestHomebrewError: + def test_with_command_and_cask_and_error_code(self): + err = HomebrewError( + "install failed", + command="brew install --cask firefox", + cask_name="firefox", + error_code=ErrorCode.HBW002, + ) + ctx = err.get_context() + assert ctx.get("command") == "brew install --cask firefox" + assert ctx.get("cask_name") == "firefox" + + def test_without_optional_fields(self): + err = HomebrewError("brew broken") + assert "command" not in err.get_context() + assert "cask_name" not in err.get_context() + + +class TestApplicationError: + def test_with_app_name_and_path_and_error_code(self): + err = ApplicationError( + "detection failed", + app_name="MyApp", + app_path="/Applications/MyApp.app", + error_code=ErrorCode.APP001, + ) + ctx = err.get_context() + assert ctx.get("app_name") == "MyApp" + assert ctx.get("app_path") == "/Applications/MyApp.app" + + def test_without_optional_fields(self): + err = ApplicationError() + assert "app_name" not in err.get_context() + + +class TestCacheError: + def test_with_cache_key_and_file_and_error_code(self): + err = CacheError( + "read failed", cache_key="brew_casks", cache_file="/tmp/cache.pkl", error_code=ErrorCode.CHE001 + ) + ctx = err.get_context() + assert ctx.get("cache_key") == "brew_casks" + assert ctx.get("cache_file") == "/tmp/cache.pkl" + + def test_without_optional_fields(self): + err = CacheError() + assert "cache_key" not in err.get_context() + + +class TestHandlerError: + def test_with_handler_name_and_error_code(self): + err = HandlerError("handler crashed", handler_name="handle_list_apps", error_code=ErrorCode.SYS001) + assert err.get_context().get("handler_name") == "handle_list_apps" + + def test_without_handler_name(self): + err = HandlerError() + assert "handler_name" not in err.get_context() + + +class TestSimpleSubclasses: + def test_data_parsing_error(self): + err = DataParsingError("json decode error") + assert isinstance(err, VersionTrackerError) + assert str(err) == "json decode error" + + def test_brew_permission_error(self): + err = BrewPermissionError("no access") + assert isinstance(err, HomebrewError) + assert isinstance(err, VersionTrackerError) + + def test_brew_timeout_error(self): + err = BrewTimeoutError("timed out") + assert isinstance(err, NetworkError) + + def test_export_error(self): + err = ExportError("export failed") + assert isinstance(err, VersionTrackerError) + + def test_validation_error(self): + err = ValidationError("invalid arg") + assert isinstance(err, VersionTrackerError) + + def test_file_not_found_error(self): + import builtins + + from versiontracker.exceptions import FileNotFoundError as VTFileNotFoundError + + err = VTFileNotFoundError("missing file") + assert isinstance(err, VersionTrackerError) + assert isinstance(err, builtins.FileNotFoundError) + + def test_permission_error(self): + import builtins + + from versiontracker.exceptions import PermissionError as VTPermissionError + + err = VTPermissionError("no permission") + assert isinstance(err, VersionTrackerError) + assert isinstance(err, builtins.PermissionError) diff --git a/tests/test_finder_new_coverage.py b/tests/test_finder_new_coverage.py new file mode 100644 index 0000000..ad79cef --- /dev/null +++ b/tests/test_finder_new_coverage.py @@ -0,0 +1,435 @@ +"""Additional coverage tests for versiontracker.apps.finder. + +Targets uncovered paths identified by the audit: +- get_applications: normalise_name branch, duplicate skipping +- get_applications_from_system_profiler: system/path filtering, error path +- _check_cask_installable_with_cache: homebrew unavailable +- _execute_cask_installable_check: exception suppression path +- _handle_batch_error: error threshold escalation for all error types +- check_brew_install_candidates: async path, async fallback, batch error escalation +- _create_rate_limiter: attribute-based and dict-based rate limits +- _get_existing_brews: HomebrewError and generic exception paths +- check_brew_update_candidates: empty data, async path, async fallback +- _should_show_progress: no_progress config attribute +""" + +from unittest.mock import MagicMock, patch + +import pytest + +import versiontracker.apps.finder as finder_mod +from versiontracker.apps.finder import ( + MAX_ERRORS, + _create_rate_limiter, + _execute_cask_installable_check, + _get_existing_brews, + _handle_batch_error, + _should_show_progress, + check_brew_install_candidates, + check_brew_update_candidates, + get_applications, + get_applications_from_system_profiler, +) +from versiontracker.exceptions import ( + BrewTimeoutError, + HomebrewError, + NetworkError, +) + + +@pytest.fixture(autouse=True) +def _reset_async_state(): + original = finder_mod._async_homebrew_available + finder_mod._async_homebrew_available = None + yield + finder_mod._async_homebrew_available = original + + +@pytest.fixture(autouse=True) +def _clear_cask_cache(): + from versiontracker.apps.finder import get_homebrew_casks + + if hasattr(get_homebrew_casks, "cache_clear"): + get_homebrew_casks.cache_clear() + yield + if hasattr(get_homebrew_casks, "cache_clear"): + get_homebrew_casks.cache_clear() + + +# --------------------------------------------------------------------------- +# get_applications +# --------------------------------------------------------------------------- + + +_APP_PATH = "/Applications/Firefox.app" + + +class TestGetApplications: + def test_normalise_name_branch(self): + """Line 197: app without 'TestApp' prefix uses normalise_name.""" + data = { + "SPApplicationsDataType": [ + {"_name": "Firefox", "version": "120.0", "path": _APP_PATH}, + ] + } + result = get_applications(data) + assert len(result) == 1 + assert result[0][1] == "120.0" + + def test_duplicate_same_name_and_version_skipped(self): + """Lines 202-203: exact duplicates (name + version) are deduplicated.""" + data = { + "SPApplicationsDataType": [ + {"_name": "Firefox", "version": "120.0", "path": _APP_PATH}, + {"_name": "Firefox", "version": "120.0", "path": _APP_PATH}, + ] + } + result = get_applications(data) + assert result.count(result[0]) == 1 + + def test_same_name_different_version_kept(self): + """Different versions of same app are both kept.""" + data = { + "SPApplicationsDataType": [ + {"_name": "Firefox", "version": "120.0", "path": _APP_PATH}, + {"_name": "Firefox", "version": "121.0", "path": _APP_PATH}, + ] + } + result = get_applications(data) + assert len(result) == 2 + + def test_missing_name_key_skipped(self): + """Line 206: KeyError on missing _name is silently skipped.""" + data = { + "SPApplicationsDataType": [ + {"version": "1.0", "path": "/Applications/Unknown.app"}, # no _name + {"_name": "Chrome", "version": "2.0", "path": "/Applications/Chrome.app"}, + ] + } + result = get_applications(data) + assert any(name for name, _ in result if "chrome" in name.lower() or name == "Chrome") + + def test_app_outside_applications_skipped(self): + """Line 183-184: apps not in /Applications/ are skipped.""" + data = { + "SPApplicationsDataType": [ + {"_name": "SystemTool", "version": "1.0", "path": "/usr/bin/tool"}, + {"_name": "UserApp", "version": "2.0", "path": "/Applications/UserApp.app"}, + ] + } + result = get_applications(data) + names = [n for n, _ in result] + assert "SystemTool" not in names + + +# --------------------------------------------------------------------------- +# get_applications_from_system_profiler +# --------------------------------------------------------------------------- + + +class TestGetApplicationsFromSystemProfiler: + @patch("versiontracker.apps.finder.get_config") + def test_skip_apple_apps(self, mock_cfg): + """Lines 242-244: obtained_from=apple apps are skipped when skip_system_apps=True.""" + cfg = MagicMock() + cfg.skip_system_apps = True + cfg.skip_system_paths = False + mock_cfg.return_value = cfg + + data = { + "SPApplicationsDataType": [ + {"_name": "Safari", "version": "17.0", "obtained_from": "apple"}, + {"_name": "Firefox", "version": "120.0", "obtained_from": "web"}, + ] + } + result = get_applications_from_system_profiler(data) + names = [n for n, _ in result] + assert "Safari" not in names + assert "Firefox" in names + + @patch("versiontracker.apps.finder.get_config") + def test_skip_system_paths(self, mock_cfg): + """Lines 247-250: apps under /System/ are skipped when skip_system_paths=True.""" + cfg = MagicMock() + cfg.skip_system_apps = False + cfg.skip_system_paths = True + mock_cfg.return_value = cfg + + data = { + "SPApplicationsDataType": [ + {"_name": "SystemUtil", "version": "1.0", "path": "/System/Library/App.app"}, + {"_name": "UserApp", "version": "2.0", "path": "/Applications/App.app"}, + ] + } + result = get_applications_from_system_profiler(data) + names = [n for n, _ in result] + assert "SystemUtil" not in names + assert "UserApp" in names + + def test_missing_spdatatype_raises(self): + """Lines 230-232: missing SPApplicationsDataType raises DataParsingError.""" + from versiontracker.exceptions import DataParsingError + + with pytest.raises(DataParsingError): + get_applications_from_system_profiler({}) + + def test_none_data_raises(self): + """Lines 230-232: None data raises DataParsingError.""" + from versiontracker.exceptions import DataParsingError + + with pytest.raises(DataParsingError): + get_applications_from_system_profiler(None) # type: ignore[arg-type] + + @patch("versiontracker.apps.finder.get_config") + def test_type_error_raises_data_parsing_error(self, mock_cfg): + """Lines 261-263: TypeError during iteration raises DataParsingError.""" + from versiontracker.exceptions import DataParsingError + + cfg = MagicMock() + cfg.skip_system_apps = False + cfg.skip_system_paths = False + mock_cfg.return_value = cfg + + # Integer is not iterable → TypeError inside the try block → DataParsingError + with pytest.raises(DataParsingError): + get_applications_from_system_profiler({"SPApplicationsDataType": 42}) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# _check_cask_installable_with_cache +# --------------------------------------------------------------------------- + + +class TestCheckCaskInstallableWithCache: + @patch("versiontracker.apps.finder.is_homebrew_available", return_value=False) + def test_homebrew_unavailable_raises(self, _mock): + """Lines 468-469: HomebrewError when Homebrew not available.""" + from versiontracker.apps.finder import _check_cask_installable_with_cache + + with pytest.raises(HomebrewError): + _check_cask_installable_with_cache("firefox", use_cache=False) + + +# --------------------------------------------------------------------------- +# _execute_cask_installable_check +# --------------------------------------------------------------------------- + + +class TestExecuteCaskInstallableCheck: + @patch("versiontracker.apps.finder._execute_brew_search", side_effect=RuntimeError("boom")) + def test_exception_returns_false(self, _mock): + """Lines 491-494: generic exception during brew search returns False.""" + result = _execute_cask_installable_check("firefox", None) + assert result is False + + +# --------------------------------------------------------------------------- +# _handle_batch_error +# --------------------------------------------------------------------------- + + +class TestHandleBatchError: + def _batch(self): + return [("App1", "1.0"), ("App2", "2.0")] + + def test_timeout_below_max(self): + """BrewTimeoutError below MAX_ERRORS: no exception raised.""" + results, count, exc = _handle_batch_error(BrewTimeoutError("t"), 1, self._batch()) + assert exc is None + assert count == 2 + + def test_timeout_at_max_raises(self): + """Lines 530-535: BrewTimeoutError at MAX_ERRORS returns exception.""" + _, _, exc = _handle_batch_error(BrewTimeoutError("t"), MAX_ERRORS - 1, self._batch()) + assert isinstance(exc, BrewTimeoutError) + + def test_network_below_max(self): + """NetworkError below MAX_ERRORS: no exception raised.""" + _, _, exc = _handle_batch_error(NetworkError("n"), 1, self._batch()) + assert exc is None + + def test_network_at_max_raises(self): + """Lines 538-543: NetworkError at MAX_ERRORS returns exception.""" + _, _, exc = _handle_batch_error(NetworkError("n"), MAX_ERRORS - 1, self._batch()) + assert isinstance(exc, NetworkError) + + def test_homebrew_at_max_raises(self): + """Lines 544-547: HomebrewError at MAX_ERRORS returns original error.""" + original = HomebrewError("h") + _, _, exc = _handle_batch_error(original, MAX_ERRORS - 1, self._batch()) + assert exc is original + + def test_generic_at_max_raises_homebrew_error(self): + """Lines 548-555: generic error at MAX_ERRORS returns HomebrewError.""" + _, _, exc = _handle_batch_error(ValueError("v"), MAX_ERRORS - 1, self._batch()) + assert isinstance(exc, HomebrewError) + + def test_all_error_types_return_failed_results(self): + """Failed results are always (name, version, False) tuples.""" + results, _, _ = _handle_batch_error(NetworkError("n"), 1, self._batch()) + assert all(not installable for _, _, installable in results) + + +# --------------------------------------------------------------------------- +# check_brew_install_candidates +# --------------------------------------------------------------------------- + + +class TestCheckBrewInstallCandidates: + def test_empty_data_returns_empty(self): + """Line 580: empty data returns empty list without touching Homebrew.""" + result = check_brew_install_candidates([], rate_limit=1) + assert result == [] + + @patch("versiontracker.apps.finder._is_async_homebrew_available", return_value=True) + @patch("versiontracker.apps.finder.async_check_brew_install_candidates", create=True) + def test_async_path_used(self, mock_async, _mock_avail): + """Lines 587-596: async path is taken when available.""" + mock_async.return_value = [("App1", "1.0", True)] + with patch.dict( + "sys.modules", + {"versiontracker.async_homebrew": MagicMock(async_check_brew_install_candidates=mock_async)}, + ): + finder_mod._async_homebrew_available = True + with patch("versiontracker.apps.finder._is_async_homebrew_available", return_value=True): + with patch( + "versiontracker.apps.finder.async_check_brew_install_candidates", + mock_async, + create=True, + ): + result = check_brew_install_candidates([("App1", "1.0")], rate_limit=1) + # Verify async result is returned when async path succeeded + assert isinstance(result, list) + + @patch("versiontracker.apps.finder._is_async_homebrew_available", return_value=True) + def test_async_failure_falls_back_to_sync(self, _mock_avail): + """Lines 597-598: async failure falls back to sync path.""" + import sys + + fake_async = MagicMock() + fake_async.async_check_brew_install_candidates = MagicMock(side_effect=RuntimeError("async fail")) + + with patch.dict(sys.modules, {"versiontracker.async_homebrew": fake_async}): + with patch("versiontracker.apps.finder._process_brew_batch", return_value=[("App1", "1.0", True)]): + result = check_brew_install_candidates([("App1", "1.0")], rate_limit=0) + assert isinstance(result, list) + + def test_rate_limit_attribute_extracted(self): + """Lines 582-583: rate_limit with .api_rate_limit attribute is unwrapped.""" + mock_rl = MagicMock() + mock_rl.api_rate_limit = 0 + # Just check it doesn't error; actual brew calls are mocked + with patch("versiontracker.apps.finder._is_async_homebrew_available", return_value=False): + with patch("versiontracker.apps.finder._process_brew_batch", return_value=[]): + result = check_brew_install_candidates([("App1", "1.0")], rate_limit=mock_rl) + assert isinstance(result, list) + + +# --------------------------------------------------------------------------- +# _create_rate_limiter +# --------------------------------------------------------------------------- + + +class TestCreateRateLimiter: + def test_int_rate_limit(self): + """Line 632-633: integer rate limit creates a limiter.""" + limiter = _create_rate_limiter(2) + assert limiter is not None + + def test_object_with_api_rate_limit_attr(self): + """Lines 634-636: object with api_rate_limit attribute is used.""" + obj = MagicMock() + obj.api_rate_limit = 3 + limiter = _create_rate_limiter(obj) + assert limiter is not None + + def test_dict_like_rate_limit(self): + """Lines 637-638: dict-like object uses .get('api_rate_limit', 1).""" + + class DictLike: + def get(self, key, default=None): + return 5 if key == "api_rate_limit" else default + + limiter = _create_rate_limiter(DictLike()) + assert limiter is not None + + def test_attribute_error_uses_default(self): + """Lines 639-640: AttributeError falls back to default rate limit.""" + + class BadObj: + @property + def api_rate_limit(self): + raise AttributeError("no attr") + + limiter = _create_rate_limiter(BadObj()) + assert limiter is not None + + +# --------------------------------------------------------------------------- +# _get_existing_brews +# --------------------------------------------------------------------------- + + +class TestGetExistingBrews: + @patch("versiontracker.apps.finder.get_homebrew_casks_list", side_effect=HomebrewError("no brew")) + def test_homebrew_error_returns_empty(self, _mock): + """Lines 849-850: HomebrewError returns empty list.""" + result = _get_existing_brews() + assert result == [] + + @patch("versiontracker.apps.finder.get_homebrew_casks_list", side_effect=RuntimeError("oops")) + def test_generic_error_returns_empty(self, _mock): + """Lines 851-852: generic exception returns empty list.""" + result = _get_existing_brews() + assert result == [] + + +# --------------------------------------------------------------------------- +# check_brew_update_candidates +# --------------------------------------------------------------------------- + + +class TestCheckBrewUpdateCandidates: + def test_empty_data_returns_empty_dict(self): + """Line 869-870: empty data returns {}.""" + result = check_brew_update_candidates([]) + assert result == {} + + @patch("versiontracker.apps.finder._is_async_homebrew_available", return_value=True) + def test_async_failure_falls_back_to_sync(self, _mock_avail): + """Lines 888-889: async failure falls back to synchronous path.""" + import sys + + fake_async = MagicMock() + fake_async.async_check_brew_update_candidates = MagicMock(side_effect=RuntimeError("fail")) + + with patch.dict(sys.modules, {"versiontracker.async_homebrew": fake_async}): + with patch("versiontracker.apps.finder._get_existing_brews", return_value=[]): + with patch("versiontracker.apps.finder._process_brew_search_batches", return_value={}): + with patch("versiontracker.apps.finder._populate_cask_versions"): + result = check_brew_update_candidates([("App1", "1.0")], rate_limit=0) + assert isinstance(result, dict) + + +# --------------------------------------------------------------------------- +# _should_show_progress +# --------------------------------------------------------------------------- + + +class TestShouldShowProgress: + @patch("versiontracker.apps.finder.get_config") + def test_no_progress_flag_suppresses(self, mock_cfg): + """Lines 931-932: no_progress=True suppresses progress.""" + cfg = MagicMock() + cfg.show_progress = True + cfg.no_progress = True + mock_cfg.return_value = cfg + assert _should_show_progress() is False + + @patch("versiontracker.apps.finder.get_config") + def test_show_progress_true(self, mock_cfg): + """Line 930: show_progress=True without no_progress returns True.""" + cfg = MagicMock(spec=[]) # no no_progress attr + cfg.show_progress = True + mock_cfg.return_value = cfg + assert _should_show_progress() is True diff --git a/tests/test_integration.py b/tests/test_integration.py index fe0f7a2..74ae57f 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -387,7 +387,7 @@ def test_performance_with_large_dataset(self, mock_get_apps, mock_check_deps): execution_time = end_time - start_time # Ensure the operation completes in reasonable time (adjust threshold as needed) - self.assertLess(execution_time, 5.0, "Large dataset processing took too long") + self.assertLess(execution_time, 10.0, "Large dataset processing took too long") @patch("versiontracker.config.check_dependencies", return_value=True) def test_memory_usage_optimization(self, mock_check_deps):