diff --git a/.gitignore b/.gitignore index 60b284f..bdc7265 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ build/ dist/ *.egg *.egg-info/ +.coverage diff --git a/pyproject.toml b/pyproject.toml index 109b826..98afb9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,5 +136,18 @@ extra_checks = true # Error on missing imports (don't allow implicit Any) disallow_any_unimported = true +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false +disallow_incomplete_defs = false +disallow_untyped_decorators = false + +[tool.coverage.run] +omit = [ + "solc_select/__main__.py", + "solc_select/solc_select.py", + "solc_select/utils.py" +] + [tool.uv] python-preference = "only-managed" diff --git a/solc_select/services/artifact_manager.py b/solc_select/services/artifact_manager.py index 8e06ad2..21bf482 100644 --- a/solc_select/services/artifact_manager.py +++ b/solc_select/services/artifact_manager.py @@ -100,17 +100,16 @@ def _download_artifact(self, artifact: SolcArtifact) -> None: response = self.session.get(artifact.download_url, stream=True) response.raise_for_status() - with open(artifact.file_path, "w+b", opener=partial(os.open, mode=0o664)) as f: - try: + try: + with open(artifact.file_path, "w+b", opener=partial(os.open, mode=0o664)) as f: for chunk in response.iter_content(chunk_size=8192): if chunk: f.write(chunk) - except KeyboardInterrupt: - if artifact.file_path.exists(): - artifact.file_path.unlink(missing_ok=True) - raise - - self.verify_checksum(artifact, f) + self.verify_checksum(artifact, f) + except KeyboardInterrupt: + if artifact.file_path.exists(): + artifact.file_path.unlink(missing_ok=True) + raise def download_and_install(self, version: SolcVersion, silent: bool = False) -> bool: """Download and install a Solidity compiler version.""" diff --git a/tests/README.md b/tests/README.md index 5f46876..cfb5803 100644 --- a/tests/README.md +++ b/tests/README.md @@ -4,10 +4,28 @@ This directory contains the pytest-based test suite for solc-select. ## Test Structure -- `conftest.py` - Pytest configuration and fixtures for test isolation +The test suite is organized into two main categories: + +### Integration Tests (`integration/`) +- End-to-end tests that verify actual CLI behavior +- Real HTTP requests, filesystem operations, and binary execution +- Platform-specific validation - `test_compiler_versions.py` - Tests for different Solidity compiler versions - `test_platform_specific.py` - Platform-specific boundary tests - `test_upgrade.py` - Tests for upgrade functionality +- `test_network_isolation.py` - Verifies offline execution after installation +- `test_version_verification.py` - Version and checksum verification + +### Unit Tests (`unit/`) +- Fast, isolated tests with mocked dependencies +- Service layer business logic +- Repository matching algorithms +- Checksum verification and parallel downloads +- Platform detection and emulation handling +- Organized by layer: + - `services/` - Service layer tests (VersionManager, ArtifactManager, etc.) + - `infrastructure/` - Infrastructure layer tests (FilesystemManager, HTTP client) + - `models/` - Domain model tests (VersionRange, SolcArtifact, etc.) ## Running Tests @@ -15,21 +33,43 @@ This directory contains the pytest-based test suite for solc-select. ```bash # Install with all development dependencies (testing + linting) -pip install -e ".[dev]" +uv pip install -e ".[dev]" ``` ### Run all tests ```bash -pytest +# Run all tests (both unit and integration) +uv run pytest tests/ + +# Run only unit tests (fast) +uv run pytest tests/unit/ -v + +# Run only integration tests (slower, requires network) +uv run pytest tests/integration/ -v ``` ### Run specific test files ```bash -pytest tests/test_compiler_versions.py -pytest tests/test_platform_specific.py -pytest tests/test_upgrade.py +# Unit tests +uv run pytest tests/unit/services/test_version_manager.py +uv run pytest tests/unit/services/test_artifact_manager.py + +# Integration tests +uv run pytest tests/integration/test_compiler_versions.py +uv run pytest tests/integration/test_platform_specific.py +uv run pytest tests/integration/test_upgrade.py +``` + +### Check test coverage + +```bash +# Coverage for unit tests +uv run pytest tests/unit/ --cov=solc_select --cov-report=term-missing + +# Coverage for all tests +uv run pytest tests/ --cov=solc_select --cov-report=html ``` ### Run platform-specific tests @@ -56,16 +96,23 @@ pytest -n auto ## Test Fixtures -The test suite uses several fixtures to ensure proper isolation: +The test suite uses different fixtures depending on the test type: +### Integration Test Fixtures (`integration/conftest.py`) - `isolated_solc_data` - Creates isolated solc-select data environment using VIRTUAL_ENV - `isolated_python_env` - Creates completely isolated Python environment for install/uninstall tests - `test_contracts_dir` - Path to test Solidity contracts in `tests/solidity_tests/` - -### Helper Functions - -- `run_command` - Executes shell commands for tests using `isolated_solc_data` -- `run_in_venv` - Executes commands in isolated virtual environments +- Helper functions: + - `run_command` - Executes shell commands for tests using `isolated_solc_data` + - `run_in_venv` - Executes commands in isolated virtual environments + +### Unit Test Fixtures (`unit/conftest.py`) +- `mock_session` - Mock requests.Session for HTTP calls +- `mock_filesystem` - Mock FilesystemManager +- `mock_platform` - Mock Platform (linux-amd64 by default) +- `mock_repository` - Mock SolcRepository with common version set +- `temp_artifacts_dir` - Temporary artifacts directory for testing +- `temp_solc_select_dir` - Temporary solc-select directory with isolated paths ## Test Organization diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/integration/conftest.py similarity index 98% rename from tests/conftest.py rename to tests/integration/conftest.py index 27fae38..ece366b 100644 --- a/tests/conftest.py +++ b/tests/integration/conftest.py @@ -74,7 +74,7 @@ def isolated_python_env(tmp_path: Path) -> Generator[dict[str, Any], None, None] @pytest.fixture(scope="session") def test_contracts_dir() -> Path: """Path to test Solidity contracts.""" - return Path(__file__).parent / "solidity_tests" + return Path(__file__).parent.parent / "solidity_tests" # Platform markers for conditional test execution diff --git a/tests/test_compiler_versions.py b/tests/integration/test_compiler_versions.py similarity index 100% rename from tests/test_compiler_versions.py rename to tests/integration/test_compiler_versions.py diff --git a/tests/test_network_isolation.py b/tests/integration/test_network_isolation.py similarity index 100% rename from tests/test_network_isolation.py rename to tests/integration/test_network_isolation.py diff --git a/tests/test_platform_specific.py b/tests/integration/test_platform_specific.py similarity index 100% rename from tests/test_platform_specific.py rename to tests/integration/test_platform_specific.py diff --git a/tests/test_upgrade.py b/tests/integration/test_upgrade.py similarity index 96% rename from tests/test_upgrade.py rename to tests/integration/test_upgrade.py index 32ae6a2..2ae4e91 100644 --- a/tests/test_upgrade.py +++ b/tests/integration/test_upgrade.py @@ -23,7 +23,7 @@ def test_upgrade_preserves_versions(self, isolated_python_env: Any) -> None: and verifies everything is preserved. """ venv = isolated_python_env - project_root = Path(__file__).parent.parent + project_root = Path(__file__).parent.parent.parent # Install release version from PyPI run_in_venv(venv, 'pip install "solc-select>=1.0"', check=True) @@ -70,7 +70,7 @@ def test_upgrade_preserves_versions(self, isolated_python_env: Any) -> None: def test_cache_already_installed(self, isolated_python_env: Any) -> None: venv = isolated_python_env - project_root = Path(__file__).parent.parent + project_root = Path(__file__).parent.parent.parent # Install development version run_in_venv(venv, f"pip install -e {project_root}", check=True) diff --git a/tests/test_version_verification.py b/tests/integration/test_version_verification.py similarity index 100% rename from tests/test_version_verification.py rename to tests/integration/test_version_verification.py diff --git a/tests/utils.py b/tests/integration/utils.py similarity index 100% rename from tests/utils.py rename to tests/integration/utils.py diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..e0e1c02 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,80 @@ +"""Fixtures for unit tests with mocking.""" + +from unittest.mock import Mock + +import pytest +import requests + +from solc_select.infrastructure.filesystem import FilesystemManager +from solc_select.models.platforms import Platform +from solc_select.models.versions import SolcVersion +from solc_select.repositories import SolcRepository + + +@pytest.fixture +def mock_session(): + """Mock requests.Session for HTTP calls.""" + return Mock(spec=requests.Session) + + +@pytest.fixture +def mock_filesystem(): + """Mock FilesystemManager.""" + return Mock(spec=FilesystemManager) + + +@pytest.fixture +def mock_platform(): + """Mock Platform (linux-amd64 by default).""" + return Platform("linux", "amd64") + + +@pytest.fixture +def mock_repository(): + """Mock SolcRepository with common version set.""" + mock = Mock(spec=SolcRepository) + mock.available_versions = { + SolcVersion("0.8.19"): "solc-linux-amd64-v0.8.19+commit.abc123", + SolcVersion("0.8.20"): "solc-linux-amd64-v0.8.20+commit.def456", + SolcVersion("0.8.21"): "solc-linux-amd64-v0.8.21+commit.ghi789", + } + mock.latest_version = SolcVersion("0.8.21") + return mock + + +@pytest.fixture +def sample_versions(): + """Sample version list for testing.""" + return [ + SolcVersion("0.8.19"), + SolcVersion("0.8.20"), + SolcVersion("0.8.21"), + ] + + +@pytest.fixture +def temp_artifacts_dir(tmp_path, monkeypatch): + """Temporary artifacts directory for testing filesystem operations.""" + artifacts_dir = tmp_path / "artifacts" + artifacts_dir.mkdir() + monkeypatch.setattr("solc_select.constants.ARTIFACTS_DIR", artifacts_dir) + return artifacts_dir + + +@pytest.fixture +def temp_solc_select_dir(tmp_path, monkeypatch): + """Temporary solc-select directory for testing.""" + solc_select_dir = tmp_path / ".solc-select" + solc_select_dir.mkdir() + artifacts_dir = solc_select_dir / "artifacts" + artifacts_dir.mkdir() + + # Patch constants module + monkeypatch.setattr("solc_select.constants.SOLC_SELECT_DIR", solc_select_dir) + monkeypatch.setattr("solc_select.constants.ARTIFACTS_DIR", artifacts_dir) + + # Patch where constants are imported in filesystem module + monkeypatch.setattr("solc_select.infrastructure.filesystem.ARTIFACTS_DIR", artifacts_dir) + monkeypatch.setattr("solc_select.infrastructure.filesystem.SOLC_SELECT_DIR", solc_select_dir) + + return solc_select_dir diff --git a/tests/unit/infrastructure/__init__.py b/tests/unit/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/infrastructure/test_filesystem.py b/tests/unit/infrastructure/test_filesystem.py new file mode 100644 index 0000000..bd10e46 --- /dev/null +++ b/tests/unit/infrastructure/test_filesystem.py @@ -0,0 +1,374 @@ +"""Unit tests for FilesystemManager.""" + +import shutil + +from solc_select.infrastructure.filesystem import FilesystemManager +from solc_select.models.versions import SolcVersion + + +class TestFilesystemManagerVersion: + """Tests for version management functionality.""" + + def test_get_current_version_from_env(self, temp_solc_select_dir, monkeypatch) -> None: + """SOLC_VERSION environment variable takes precedence.""" + monkeypatch.setenv("SOLC_VERSION", "0.8.19") + fm = FilesystemManager() + + version = fm.get_current_version() + + assert version == SolcVersion("0.8.19") + + def test_get_current_version_from_file(self, temp_solc_select_dir) -> None: + """Reads version from global-version file when env var not set.""" + global_version_file = temp_solc_select_dir / "global-version" + global_version_file.write_text("0.8.20") + fm = FilesystemManager() + + version = fm.get_current_version() + + assert version == SolcVersion("0.8.20") + + def test_get_current_version_env_precedence(self, temp_solc_select_dir, monkeypatch) -> None: + """Environment variable overrides file setting.""" + # Set file version + global_version_file = temp_solc_select_dir / "global-version" + global_version_file.write_text("0.8.20") + + # Set env version + monkeypatch.setenv("SOLC_VERSION", "0.8.19") + fm = FilesystemManager() + + version = fm.get_current_version() + + # Env var should win + assert version == SolcVersion("0.8.19") + + def test_get_current_version_none_set(self, temp_solc_select_dir) -> None: + """Returns None when no version is configured.""" + fm = FilesystemManager() + version = fm.get_current_version() + + assert version is None + + def test_get_current_version_invalid_env_format( + self, temp_solc_select_dir, monkeypatch + ) -> None: + """Returns None gracefully when env var has invalid format.""" + monkeypatch.setenv("SOLC_VERSION", "invalid-version") + fm = FilesystemManager() + + version = fm.get_current_version() + + assert version is None + + def test_get_current_version_invalid_file_format(self, temp_solc_select_dir) -> None: + """Returns None gracefully when file has invalid format.""" + global_version_file = temp_solc_select_dir / "global-version" + global_version_file.write_text("not-a-version") + fm = FilesystemManager() + + version = fm.get_current_version() + + assert version is None + + def test_set_global_version(self, temp_solc_select_dir) -> None: + """Writes version to global-version file correctly.""" + fm = FilesystemManager() + version = SolcVersion("0.8.21") + + fm.set_global_version(version) + + global_version_file = temp_solc_select_dir / "global-version" + assert global_version_file.exists() + assert global_version_file.read_text() == "0.8.21" + + def test_get_version_source_env(self, temp_solc_select_dir, monkeypatch) -> None: + """Returns 'SOLC_VERSION' when version is from environment.""" + monkeypatch.setenv("SOLC_VERSION", "0.8.19") + fm = FilesystemManager() + + source = fm.get_version_source() + + assert source == "SOLC_VERSION" + + def test_get_version_source_file(self, temp_solc_select_dir) -> None: + """Returns file path when version is from file.""" + fm = FilesystemManager() + source = fm.get_version_source() + + expected_path = (temp_solc_select_dir / "global-version").as_posix() + assert source == expected_path + + +class TestFilesystemManagerInstalled: + """Tests for installed version detection.""" + + def test_get_installed_versions_multiple(self, temp_solc_select_dir) -> None: + """Scans artifacts directory and finds multiple installed versions.""" + artifacts_dir = temp_solc_select_dir / "artifacts" + + # Create multiple version directories with binaries + versions = ["0.8.19", "0.8.20", "0.8.21"] + for version_str in versions: + version_dir = artifacts_dir / f"solc-{version_str}" + version_dir.mkdir() + binary = version_dir / f"solc-{version_str}" + binary.write_text("fake binary") + + fm = FilesystemManager() + installed = fm.get_installed_versions() + + assert len(installed) == 3 + assert SolcVersion("0.8.19") in installed + assert SolcVersion("0.8.20") in installed + assert SolcVersion("0.8.21") in installed + + def test_get_installed_versions_filters_invalid(self, temp_solc_select_dir) -> None: + """Skips non-version directories and invalid formats.""" + artifacts_dir = temp_solc_select_dir / "artifacts" + + # Create valid version + valid_dir = artifacts_dir / "solc-0.8.19" + valid_dir.mkdir() + (valid_dir / "solc-0.8.19").write_text("binary") + + # Create invalid entries + (artifacts_dir / "not-a-version").mkdir() + (artifacts_dir / "solc-invalid").mkdir() + (artifacts_dir / "random.txt").write_text("file") + + fm = FilesystemManager() + installed = fm.get_installed_versions() + + assert len(installed) == 1 + assert installed[0] == SolcVersion("0.8.19") + + def test_get_installed_versions_sorted(self, temp_solc_select_dir) -> None: + """Returns versions sorted by version number.""" + artifacts_dir = temp_solc_select_dir / "artifacts" + + # Create versions in random order + versions = ["0.8.21", "0.8.19", "0.8.20", "0.8.17", "0.8.25"] + for version_str in versions: + version_dir = artifacts_dir / f"solc-{version_str}" + version_dir.mkdir() + (version_dir / f"solc-{version_str}").write_text("binary") + + fm = FilesystemManager() + installed = fm.get_installed_versions() + + # Should be sorted + assert installed == [ + SolcVersion("0.8.17"), + SolcVersion("0.8.19"), + SolcVersion("0.8.20"), + SolcVersion("0.8.21"), + SolcVersion("0.8.25"), + ] + + def test_get_installed_versions_empty_dir(self, temp_solc_select_dir) -> None: + """Returns empty list when artifacts directory is empty.""" + fm = FilesystemManager() + installed = fm.get_installed_versions() + + assert installed == [] + + def test_get_installed_versions_only_directories_with_binaries( + self, temp_solc_select_dir + ) -> None: + """Only includes directories that actually contain the binary.""" + artifacts_dir = temp_solc_select_dir / "artifacts" + + # Create directory with binary + valid_dir = artifacts_dir / "solc-0.8.19" + valid_dir.mkdir() + (valid_dir / "solc-0.8.19").write_text("binary") + + # Create directory without binary + empty_dir = artifacts_dir / "solc-0.8.20" + empty_dir.mkdir() + + fm = FilesystemManager() + installed = fm.get_installed_versions() + + assert len(installed) == 1 + assert installed[0] == SolcVersion("0.8.19") + + def test_is_installed_true(self, temp_solc_select_dir) -> None: + """Returns True when binary exists.""" + artifacts_dir = temp_solc_select_dir / "artifacts" + + version = SolcVersion("0.8.19") + version_dir = artifacts_dir / "solc-0.8.19" + version_dir.mkdir() + binary = version_dir / "solc-0.8.19" + binary.write_text("fake binary") + + fm = FilesystemManager() + assert fm.is_installed(version) is True + + def test_is_installed_false(self, temp_solc_select_dir) -> None: + """Returns False when binary doesn't exist.""" + version = SolcVersion("0.8.19") + + fm = FilesystemManager() + assert fm.is_installed(version) is False + + def test_is_installed_false_directory_exists_but_no_binary(self, temp_solc_select_dir) -> None: + """Returns False when directory exists but binary is missing.""" + artifacts_dir = temp_solc_select_dir / "artifacts" + + version = SolcVersion("0.8.19") + version_dir = artifacts_dir / "solc-0.8.19" + version_dir.mkdir() + # Directory exists but no binary inside + + fm = FilesystemManager() + assert fm.is_installed(version) is False + + +class TestFilesystemManagerPaths: + """Tests for path construction methods.""" + + def test_get_artifact_directory(self, temp_solc_select_dir) -> None: + """Constructs correct artifact directory path.""" + version = SolcVersion("0.8.19") + fm = FilesystemManager() + + artifact_dir = fm.get_artifact_directory(version) + + expected = temp_solc_select_dir / "artifacts" / "solc-0.8.19" + assert artifact_dir == expected + + def test_get_binary_path(self, temp_solc_select_dir) -> None: + """Constructs correct binary path.""" + version = SolcVersion("0.8.19") + fm = FilesystemManager() + + binary_path = fm.get_binary_path(version) + + expected = temp_solc_select_dir / "artifacts" / "solc-0.8.19" / "solc-0.8.19" + assert binary_path == expected + + def test_ensure_artifact_directory_creates_directory(self, temp_solc_select_dir) -> None: + """Creates artifact directory if it doesn't exist.""" + version = SolcVersion("0.8.19") + fm = FilesystemManager() + + artifact_dir = fm.ensure_artifact_directory(version) + + assert artifact_dir.exists() + assert artifact_dir.is_dir() + + def test_ensure_artifact_directory_idempotent(self, temp_solc_select_dir) -> None: + """Can be called multiple times without error.""" + version = SolcVersion("0.8.19") + fm = FilesystemManager() + + # Call twice + dir1 = fm.ensure_artifact_directory(version) + dir2 = fm.ensure_artifact_directory(version) + + assert dir1 == dir2 + assert dir1.exists() + + +class TestFilesystemManagerLegacy: + """Tests for legacy installation detection and cleanup.""" + + def test_is_legacy_installation_file(self, temp_solc_select_dir) -> None: + """Detects legacy installation (file instead of directory).""" + artifacts_dir = temp_solc_select_dir / "artifacts" + + version = SolcVersion("0.8.19") + # Legacy format: binary is directly in artifacts dir + legacy_binary = artifacts_dir / f"solc-{version}" + legacy_binary.write_text("legacy binary") + + fm = FilesystemManager() + assert fm.is_legacy_installation(version) is True + + def test_is_legacy_installation_directory(self, temp_solc_select_dir) -> None: + """Returns False for new installation format (directory).""" + artifacts_dir = temp_solc_select_dir / "artifacts" + + version = SolcVersion("0.8.19") + # New format: binary is in subdirectory + version_dir = artifacts_dir / f"solc-{version}" + version_dir.mkdir() + (version_dir / f"solc-{version}").write_text("new binary") + + fm = FilesystemManager() + assert fm.is_legacy_installation(version) is False + + def test_is_legacy_installation_not_exists(self, temp_solc_select_dir) -> None: + """Returns False when nothing exists.""" + version = SolcVersion("0.8.19") + + fm = FilesystemManager() + assert fm.is_legacy_installation(version) is False + + def test_cleanup_artifacts_directory_removes_all(self, temp_solc_select_dir) -> None: + """Removes entire artifacts directory for upgrades.""" + artifacts_dir = temp_solc_select_dir / "artifacts" + + # Create multiple versions + versions = ["0.8.19", "0.8.20", "0.8.21"] + for version_str in versions: + version_dir = artifacts_dir / f"solc-{version_str}" + version_dir.mkdir() + (version_dir / f"solc-{version_str}").write_text("binary") + + # Add some other files + (artifacts_dir / "random.txt").write_text("data") + + fm = FilesystemManager() + fm.cleanup_artifacts_directory() + + # Directory should still exist but be empty + assert artifacts_dir.exists() + assert list(artifacts_dir.iterdir()) == [] + + def test_cleanup_artifacts_directory_recreates(self, temp_solc_select_dir) -> None: + """Recreates artifacts directory after removal.""" + artifacts_dir = temp_solc_select_dir / "artifacts" + + # Add content + (artifacts_dir / "test.txt").write_text("data") + + fm = FilesystemManager() + fm.cleanup_artifacts_directory() + + # Should exist and be usable + assert artifacts_dir.exists() + assert artifacts_dir.is_dir() + + # Should be able to create new content + (artifacts_dir / "new.txt").write_text("new data") + assert (artifacts_dir / "new.txt").exists() + + +class TestFilesystemManagerInitialization: + """Tests for FilesystemManager initialization.""" + + def test_init_creates_directories(self, temp_solc_select_dir) -> None: + """Initialization creates required directories.""" + artifacts_dir = temp_solc_select_dir / "artifacts" + + # Remove artifacts dir to test creation + if artifacts_dir.exists(): + shutil.rmtree(artifacts_dir) + + # Create new instance after removal + FilesystemManager() + + assert temp_solc_select_dir.exists() + assert artifacts_dir.exists() + + def test_init_idempotent(self, temp_solc_select_dir) -> None: + """Can create multiple FilesystemManager instances.""" + fm1 = FilesystemManager() + fm2 = FilesystemManager() + + assert fm1.artifacts_dir == fm2.artifacts_dir + assert fm1.config_dir == fm2.config_dir diff --git a/tests/unit/models/__init__.py b/tests/unit/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/models/test_artifacts.py b/tests/unit/models/test_artifacts.py new file mode 100644 index 0000000..3eb4b97 --- /dev/null +++ b/tests/unit/models/test_artifacts.py @@ -0,0 +1,92 @@ +"""Unit tests for artifact-related domain models.""" + +from pathlib import Path + +import pytest + +from solc_select.models.artifacts import SolcArtifact +from solc_select.models.platforms import Platform +from solc_select.models.versions import SolcVersion + + +def create_artifact( + version: str = "0.8.19", + platform: Platform | None = None, + checksum_sha256: str = "abc123", + checksum_keccak256: str | None = None, + download_url: str = "https://example.com/solc", +) -> SolcArtifact: + """Helper to create SolcArtifact with sensible defaults.""" + if platform is None: + platform = Platform("linux", "amd64") + return SolcArtifact( + version=SolcVersion(version), + platform=platform, + file_path=Path(f"/tmp/solc-{version}"), + download_url=download_url, + checksum_sha256=checksum_sha256, + checksum_keccak256=checksum_keccak256, + ) + + +class TestSolcArtifact: + """Tests for SolcArtifact domain model.""" + + @pytest.mark.parametrize( + "version,expected", + [ + ("0.6.12", True), # Old Windows version - should be ZIP + ("0.7.1", True), # Boundary - still ZIP + ("0.7.2", False), # First non-ZIP version + ("0.8.19", False), # Modern version + ], + ) + def test_is_zip_archive_windows(self, version: str, expected: bool) -> None: + """Windows ZIP archive detection based on version boundary (0.7.1).""" + artifact = create_artifact(version=version, platform=Platform("windows", "amd64")) + assert artifact.is_zip_archive == expected + + @pytest.mark.parametrize("os_type", ["linux", "darwin"]) + def test_is_zip_archive_non_windows(self, os_type: str) -> None: + """Non-Windows platforms should never have ZIP archives.""" + artifact = create_artifact(version="0.6.12", platform=Platform(os_type, "amd64")) + assert not artifact.is_zip_archive + + def test_checksum_prefix_removal(self) -> None: + """Checksums with 0x prefix should be stripped in __post_init__.""" + artifact = create_artifact( + checksum_sha256="0xabc123def456", + checksum_keccak256="0x789xyz", + ) + assert artifact.checksum_sha256 == "abc123def456" + assert artifact.checksum_keccak256 == "789xyz" + + def test_get_binary_name_in_zip(self) -> None: + """Binary name inside ZIP archives should be solc.exe.""" + artifact = create_artifact(version="0.6.12", platform=Platform("windows", "amd64")) + assert artifact.get_binary_name_in_zip() == "solc.exe" + + def test_get_binary_name_in_zip_raises_for_non_zip(self) -> None: + """Calling get_binary_name_in_zip on non-ZIP should raise ValueError.""" + artifact = create_artifact(version="0.8.19", platform=Platform("windows", "amd64")) + with pytest.raises(ValueError, match="Not a ZIP archive"): + artifact.get_binary_name_in_zip() + + @pytest.mark.parametrize( + "download_url,checksum,error_msg", + [ + ("", "abc123", "Download URL cannot be empty"), + ("https://example.com", "", "SHA256 checksum cannot be empty"), + ], + ) + def test_validation_errors(self, download_url: str, checksum: str, error_msg: str) -> None: + """Empty URL or checksum should raise ValueError.""" + with pytest.raises(ValueError, match=error_msg): + SolcArtifact( + version=SolcVersion("0.8.19"), + platform=Platform("linux", "amd64"), + file_path=Path("/tmp/solc-0.8.19"), + download_url=download_url, + checksum_sha256=checksum, + checksum_keccak256=None, + ) diff --git a/tests/unit/models/test_versions.py b/tests/unit/models/test_versions.py new file mode 100644 index 0000000..f42f004 --- /dev/null +++ b/tests/unit/models/test_versions.py @@ -0,0 +1,95 @@ +"""Unit tests for version-related domain models.""" + +import pytest + +from solc_select.models.versions import SolcVersion, VersionRange + + +class TestVersionRange: + """Tests for VersionRange domain model.""" + + @pytest.mark.parametrize( + "version,expected", + [ + ("0.8.10", True), # Within bounds + ("0.8.0", True), # Exact minimum (inclusive) + ("0.8.20", True), # Exact maximum (inclusive) + ("0.7.6", False), # Below minimum + ("0.8.21", False), # Above maximum + ], + ) + def test_contains_bounded_range(self, version: str, expected: bool) -> None: + """VersionRange with both bounds should be inclusive [min, max].""" + version_range = VersionRange( + min_version=SolcVersion("0.8.0"), + max_version=SolcVersion("0.8.20"), + ) + assert version_range.contains(SolcVersion(version)) == expected + + def test_contains_unbounded_min(self) -> None: + """None as minimum means no lower bound.""" + version_range = VersionRange(min_version=None, max_version=SolcVersion("0.8.20")) + assert version_range.contains(SolcVersion("0.4.0")) + assert version_range.contains(SolcVersion("0.8.20")) + assert not version_range.contains(SolcVersion("0.8.21")) + + def test_contains_unbounded_max(self) -> None: + """None as maximum means no upper bound.""" + version_range = VersionRange(min_version=SolcVersion("0.8.0"), max_version=None) + assert version_range.contains(SolcVersion("0.8.0")) + assert version_range.contains(SolcVersion("0.9.0")) + assert not version_range.contains(SolcVersion("0.7.6")) + + def test_contains_unbounded_both(self) -> None: + """None for both means all versions are valid.""" + version_range = VersionRange(min_version=None, max_version=None) + assert version_range.contains(SolcVersion("0.4.0")) + assert version_range.contains(SolcVersion("0.9.0")) + + def test_from_min_factory(self) -> None: + """Creates open-ended range [min, infinity).""" + version_range = VersionRange.from_min("0.8.5") + assert version_range.min_version == SolcVersion("0.8.5") + assert version_range.max_version is None + assert version_range.contains(SolcVersion("0.8.5")) + assert not version_range.contains(SolcVersion("0.8.4")) + + def test_exact_range_factory(self) -> None: + """Creates closed range [min, max].""" + version_range = VersionRange.exact_range("0.8.5", "0.8.23") + assert version_range.min_version == SolcVersion("0.8.5") + assert version_range.max_version == SolcVersion("0.8.23") + assert version_range.contains(SolcVersion("0.8.5")) + assert version_range.contains(SolcVersion("0.8.23")) + assert not version_range.contains(SolcVersion("0.8.4")) + assert not version_range.contains(SolcVersion("0.8.24")) + + +class TestSolcVersion: + """Tests for SolcVersion domain model.""" + + def test_parse_valid_version(self) -> None: + """Parse valid version string.""" + version = SolcVersion.parse("0.8.19") + assert version == SolcVersion("0.8.19") + assert isinstance(version, SolcVersion) + + def test_parse_latest_raises_error(self) -> None: + """Parse 'latest' should raise ValueError with clear message.""" + with pytest.raises(ValueError) as exc_info: + SolcVersion.parse("latest") + assert "Cannot parse 'latest'" in str(exc_info.value) + assert "resolve to actual version first" in str(exc_info.value) + + def test_version_comparison(self) -> None: + """SolcVersion should support comparison operations.""" + v1 = SolcVersion("0.8.19") + v2 = SolcVersion("0.8.20") + v3 = SolcVersion("0.8.19") + + assert v1 < v2 + assert v2 > v1 + assert v1 == v3 + assert v1 <= v3 + assert v1 >= v3 + assert v1 != v2 diff --git a/tests/unit/services/__init__.py b/tests/unit/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/services/test_artifact_manager.py b/tests/unit/services/test_artifact_manager.py new file mode 100644 index 0000000..876431b --- /dev/null +++ b/tests/unit/services/test_artifact_manager.py @@ -0,0 +1,453 @@ +"""Unit tests for ArtifactManager service. + +Tests cover checksum verification, download operations, bulk installs, and metadata creation. +""" + +import hashlib +import io +import sys +import zipfile +from collections.abc import Iterator +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest +import requests +from Crypto.Hash import keccak + +from solc_select.exceptions import ChecksumMismatchError +from solc_select.models.artifacts import SolcArtifact, SolcArtifactOnDisk +from solc_select.models.platforms import Platform +from solc_select.models.versions import SolcVersion +from solc_select.platform_capabilities import ( + PlatformCapability, + PlatformIdentifier, +) +from solc_select.repositories import SolcRepository +from solc_select.services.artifact_manager import ArtifactManager +from solc_select.services.repository_matcher import RepositoryMatcher + + +@pytest.fixture +def mock_repository_matcher(): + """Mock RepositoryMatcher.""" + matcher = Mock(spec=RepositoryMatcher) + mock_repo = Mock(spec=SolcRepository) + mock_repo.available_versions = { + "0.8.19": "solc-linux-amd64-v0.8.19+commit.abc123", + "0.8.20": "solc-linux-amd64-v0.8.20+commit.def456", + "0.8.21": "solc-linux-amd64-v0.8.21+commit.ghi789", + } + mock_repo.get_download_url.return_value = ( + "https://binaries.soliditylang.org/linux-amd64/solc-linux-amd64-v0.8.19+commit.abc123" + ) + mock_repo.get_checksums.return_value = ( + "abcd1234" * 8, # 64 char SHA256 + "efgh5678" * 8, # 64 char Keccak256 + ) + matcher.find_repository_for_version.return_value = ( + mock_repo, + PlatformIdentifier("linux", "amd64"), + ) + return matcher + + +@pytest.fixture +def mock_platform_capability(): + """Mock PlatformCapability.""" + return PlatformCapability( + host_platform=PlatformIdentifier("linux", "amd64"), + native_support=PlatformIdentifier("linux", "amd64"), + emulation_capabilities=[], + ) + + +@pytest.fixture +def artifact_manager( + mock_repository_matcher, mock_platform_capability, mock_platform, mock_session, mock_filesystem +): + """Create ArtifactManager with mocked dependencies.""" + return ArtifactManager( + repository_matcher=mock_repository_matcher, + platform_capability=mock_platform_capability, + platform=mock_platform, + session=mock_session, + filesystem=mock_filesystem, + ) + + +@pytest.fixture +def sample_artifact(): + """Sample SolcArtifact for testing.""" + return SolcArtifact( + version=SolcVersion("0.8.19"), + platform=Platform("linux", "amd64"), + download_url="https://binaries.soliditylang.org/linux-amd64/solc-linux-amd64-v0.8.19+commit.abc123", + checksum_sha256="abcd1234" * 8, + checksum_keccak256="efgh5678" * 8, + file_path=Path("/tmp/solc-0.8.19"), + ) + + +@pytest.fixture +def sample_artifact_windows_zip(): + """Sample Windows ZIP artifact for testing (< 0.7.1).""" + return SolcArtifact( + version=SolcVersion("0.4.26"), + platform=Platform("windows", "amd64"), + download_url="https://binaries.soliditylang.org/windows-amd64/solc-windows-amd64-v0.4.26+commit.abc123.zip", + checksum_sha256="1234abcd" * 8, + checksum_keccak256="5678efgh" * 8, + file_path=Path("/tmp/solc-0.4.26.zip"), + ) + + +class TestArtifactManagerChecksum: + """Test checksum verification functionality.""" + + def test_verify_checksum_success(self, artifact_manager, sample_artifact): + """Valid checksums (SHA256 and Keccak256) should verify successfully.""" + content = b"test content" + sample_artifact.checksum_sha256 = hashlib.sha256(content).hexdigest() + sample_artifact.checksum_keccak256 = keccak.new(digest_bits=256, data=content).hexdigest() + + artifact_manager.verify_checksum(sample_artifact, io.BytesIO(content)) + + def test_verify_checksum_sha256_mismatch(self, artifact_manager, sample_artifact): + """SHA256 mismatch should raise ChecksumMismatchError.""" + content = b"wrong content" + actual_sha256 = hashlib.sha256(content).hexdigest() + + with pytest.raises(ChecksumMismatchError) as exc_info: + artifact_manager.verify_checksum(sample_artifact, io.BytesIO(content)) + + assert exc_info.value.algorithm == "SHA256" + assert exc_info.value.expected == sample_artifact.checksum_sha256 + assert exc_info.value.actual == actual_sha256 + + def test_verify_checksum_keccak256_mismatch(self, artifact_manager, sample_artifact): + """Keccak256 mismatch should raise ChecksumMismatchError.""" + content = b"test content" + sample_artifact.checksum_sha256 = hashlib.sha256(content).hexdigest() + sample_artifact.checksum_keccak256 = "wrong_keccak256_hash" + "0" * 48 + + with pytest.raises(ChecksumMismatchError) as exc_info: + artifact_manager.verify_checksum(sample_artifact, io.BytesIO(content)) + + assert exc_info.value.algorithm == "Keccak256" + + def test_verify_checksum_large_file(self, artifact_manager, sample_artifact): + """Chunked reading should work for large files (>10MB).""" + content = b"x" * (12 * 1024 * 1024) # 12MB + sample_artifact.checksum_sha256 = hashlib.sha256(content).hexdigest() + sample_artifact.checksum_keccak256 = keccak.new(digest_bits=256, data=content).hexdigest() + + artifact_manager.verify_checksum(sample_artifact, io.BytesIO(content)) + + +class TestArtifactManagerDownload: + """Test download and installation functionality.""" + + def test_download_already_installed_skips(self, artifact_manager, mock_filesystem): + """Should return early if version is already installed.""" + version = SolcVersion("0.8.19") + mock_filesystem.is_installed.return_value = True + + result = artifact_manager.download_and_install(version, silent=True) + + assert result is True + mock_filesystem.is_installed.assert_called_once_with(version) + # Should not attempt download + artifact_manager.session.get.assert_not_called() + + def test_download_successful_standard_binary( + self, artifact_manager, mock_filesystem, mock_session, sample_artifact, tmp_path + ): + """Should download, verify checksum, and chmod +x for standard binaries.""" + version = SolcVersion("0.8.19") + binary_path = tmp_path / "solc-0.8.19" + + # Setup mocks + mock_filesystem.is_installed.return_value = False + mock_filesystem.get_binary_path.return_value = binary_path + + # Create real binary content with matching checksums + content = b"solc binary content" + sha256_hash = hashlib.sha256(content).hexdigest() + keccak_hash = keccak.new(digest_bits=256, data=content).hexdigest() + + # Mock HTTP response + mock_response = Mock() + mock_response.iter_content.return_value = [content] + mock_response.raise_for_status = Mock() + mock_session.get.return_value = mock_response + + # Mock repository to return correct checksums + mock_repo = artifact_manager.repository_matcher.find_repository_for_version.return_value[0] + mock_repo.get_checksums.return_value = (sha256_hash, keccak_hash) + + result = artifact_manager.download_and_install(version, silent=True) + + assert result is True + mock_session.get.assert_called_once() + assert binary_path.exists() + # Check file has execute permissions + if sys.platform != "win32": + assert binary_path.stat().st_mode & 0o100 # User execute bit + + def test_download_successful_zip_archive( + self, + mock_repository_matcher, + mock_platform_capability, + mock_session, + mock_filesystem, + tmp_path, + ): + """Should extract Windows ZIP archives (<0.7.1) correctly.""" + version = SolcVersion("0.4.26") + + # Create artifact directory structure + artifact_dir = tmp_path / "0.4.26" + artifact_dir.mkdir(parents=True) + + zip_path = artifact_dir / "solc-0.4.26.zip" + binary_path = artifact_dir / "solc-0.4.26.exe" + + # Setup mocks for Windows platform + mock_filesystem.is_installed.return_value = False + mock_filesystem.get_binary_path.return_value = zip_path + mock_filesystem.ensure_artifact_directory = Mock() + + # Create a real ZIP file + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr("solc.exe", b"windows binary content") + zip_content = zip_buffer.getvalue() + + # Calculate checksums for ZIP + sha256_hash = hashlib.sha256(zip_content).hexdigest() + keccak_hash = keccak.new(digest_bits=256, data=zip_content).hexdigest() + + # Mock HTTP response + mock_response = Mock() + mock_response.iter_content.return_value = [zip_content] + mock_response.raise_for_status = Mock() + mock_session.get.return_value = mock_response + + # Mock repository to return correct checksums and available_versions + mock_repo = mock_repository_matcher.find_repository_for_version.return_value[0] + mock_repo.available_versions = {"0.4.26": "solc-windows-amd64-v0.4.26+commit.abc123.zip"} + mock_repo.get_download_url.return_value = "https://example.com/solc-0.4.26.zip" + mock_repo.get_checksums.return_value = (sha256_hash, keccak_hash) + + # Create ArtifactManager with Windows platform + windows_platform = Platform("windows", "amd64") + artifact_manager = ArtifactManager( + repository_matcher=mock_repository_matcher, + platform_capability=mock_platform_capability, + platform=windows_platform, + session=mock_session, + filesystem=mock_filesystem, + ) + + # Mock _extract_zip_archive to verify it's called instead of testing full extraction + # (Full extraction is tested in integration tests) + extract_called = [] + + def mock_extract(artifact: SolcArtifact) -> None: + extract_called.append(artifact) + # Simulate extraction: remove ZIP and create binary + artifact.file_path.unlink() + binary_path.touch() + binary_path.chmod(0o775) + + with patch.object(artifact_manager, "_extract_zip_archive", side_effect=mock_extract): + result = artifact_manager.download_and_install(version, silent=True) + + assert result is True + # Verify _extract_zip_archive was called + assert len(extract_called) == 1 + assert extract_called[0].version == version + assert extract_called[0].is_zip_archive is True + # ZIP should be extracted and removed, binary should exist + assert not zip_path.exists() + assert binary_path.exists() + + def test_download_checksum_error_cleanup( + self, artifact_manager, mock_filesystem, mock_session, sample_artifact, tmp_path + ): + """Should delete file on checksum failure.""" + version = SolcVersion("0.8.19") + binary_path = tmp_path / "solc-0.8.19" + + # Setup mocks + mock_filesystem.is_installed.return_value = False + mock_filesystem.get_binary_path.return_value = binary_path + + # Create content with wrong checksum + content = b"wrong content" + mock_response = Mock() + mock_response.iter_content.return_value = [content] + mock_response.raise_for_status = Mock() + mock_session.get.return_value = mock_response + + # Repository returns expected checksums (won't match) + mock_repo = artifact_manager.repository_matcher.find_repository_for_version.return_value[0] + mock_repo.get_checksums.return_value = ("expected_sha256" * 4, "expected_keccak" * 4) + + with pytest.raises(ChecksumMismatchError): + artifact_manager.download_and_install(version, silent=True) + + # File should be cleaned up + assert not binary_path.exists() + + def test_download_network_error_cleanup( + self, artifact_manager, mock_filesystem, mock_session, tmp_path + ): + """Should clean up partial download on network error.""" + version = SolcVersion("0.8.19") + binary_path = tmp_path / "solc-0.8.19" + + # Setup mocks + mock_filesystem.is_installed.return_value = False + mock_filesystem.get_binary_path.return_value = binary_path + + # Simulate network error + mock_session.get.side_effect = requests.RequestException("Network error") + + result = artifact_manager.download_and_install(version, silent=True) + + assert result is False + # File should be cleaned up if it exists + assert not binary_path.exists() + + def test_download_keyboard_interrupt_cleanup( + self, artifact_manager, mock_filesystem, mock_session, tmp_path + ): + """Should cleanup on Ctrl+C (KeyboardInterrupt).""" + version = SolcVersion("0.8.19") + binary_path = tmp_path / "solc-0.8.19" + + # Setup mocks + mock_filesystem.is_installed.return_value = False + mock_filesystem.get_binary_path.return_value = binary_path + + # Simulate KeyboardInterrupt during download + def iter_with_interrupt() -> Iterator[bytes]: + yield b"partial content" + raise KeyboardInterrupt("User cancelled") + + mock_response = Mock() + mock_response.iter_content.return_value = iter_with_interrupt() + mock_response.raise_for_status = Mock() + mock_session.get.return_value = mock_response + + with pytest.raises(KeyboardInterrupt): + artifact_manager.download_and_install(version, silent=True) + + # File should be cleaned up + assert not binary_path.exists() + + +class TestArtifactManagerBulkInstall: + """Test bulk installation with parallel downloads.""" + + def test_install_single_version(self, artifact_manager, mock_filesystem): + """Single version should use direct download path.""" + mock_filesystem.is_installed.return_value = True + result = artifact_manager.install_versions([SolcVersion("0.8.19")], silent=True) + assert result is True + + def test_install_multiple_versions_uses_thread_pool(self, artifact_manager): + """Multiple versions should use ThreadPoolExecutor with 5 workers.""" + versions = [SolcVersion("0.8.19"), SolcVersion("0.8.20"), SolcVersion("0.8.21")] + + with ( + patch.object(artifact_manager, "download_and_install", return_value=True), + patch( + "solc_select.services.artifact_manager.ThreadPoolExecutor" + ) as mock_executor_class, + patch("solc_select.services.artifact_manager.as_completed") as mock_as_completed, + ): + mock_executor = Mock() + mock_executor_class.return_value.__enter__.return_value = mock_executor + mock_futures = [Mock(result=Mock(return_value=True)) for _ in versions] + mock_executor.submit.side_effect = mock_futures + mock_as_completed.return_value = mock_futures + + result = artifact_manager.install_versions(versions, silent=True) + + assert result is True + mock_executor_class.assert_called_once_with(max_workers=5) + assert mock_executor.submit.call_count == len(versions) + + def test_install_partial_failure_returns_false(self, artifact_manager): + """Partial installation failure should return False.""" + versions = [SolcVersion("0.8.19"), SolcVersion("0.8.20")] + call_count = [0] + + def mock_install(version, silent): + call_count[0] += 1 + return call_count[0] == 1 # Only first succeeds + + with patch.object(artifact_manager, "download_and_install", side_effect=mock_install): + result = artifact_manager.install_versions(versions, silent=True) + assert result is False + + def test_install_empty_list(self, artifact_manager): + """Empty version list should return True immediately.""" + result = artifact_manager.install_versions([], silent=True) + assert result is True + artifact_manager.session.get.assert_not_called() + + +class TestArtifactManagerMetadata: + """Test artifact metadata creation.""" + + def test_create_artifact_metadata(self, artifact_manager, mock_repository_matcher): + """Should fetch metadata from repository.""" + version = SolcVersion("0.8.19") + + artifact = artifact_manager.create_artifact_metadata(version) + + assert isinstance(artifact, SolcArtifact) + assert artifact.version == version + assert artifact.download_url.startswith("https://") + assert len(artifact.checksum_sha256) == 64 + assert artifact.checksum_keccak256 is None or len(artifact.checksum_keccak256) == 64 + mock_repository_matcher.find_repository_for_version.assert_called_once_with(version) + + def test_create_artifact_metadata_not_available( + self, artifact_manager, mock_repository_matcher + ): + """Should raise ValueError if version is not available.""" + version = SolcVersion("9.9.9") + + # Mock repository to not have this version + mock_repo = mock_repository_matcher.find_repository_for_version.return_value[0] + mock_repo.available_versions = {} + + with pytest.raises(ValueError) as exc_info: + artifact_manager.create_artifact_metadata(version) + + assert "is not available" in str(exc_info.value) + assert "9.9.9" in str(exc_info.value) + + def test_create_local_artifact_metadata( + self, artifact_manager, mock_repository_matcher, mock_filesystem + ): + """Should use local filesystem paths for installed versions.""" + version = SolcVersion("0.8.19") + expected_path = Path("/home/user/.solc-select/artifacts/0.8.19/solc-0.8.19") + mock_filesystem.get_binary_path.return_value = expected_path + + artifact = artifact_manager.create_local_artifact_metadata(version) + + assert isinstance(artifact, SolcArtifactOnDisk) + assert artifact.version == version + assert artifact.file_path == expected_path + mock_filesystem.get_binary_path.assert_called_once_with(version) + # Should use exact=False for local lookups + mock_repository_matcher.find_repository_for_version.assert_called_once_with( + version, exact=False + ) diff --git a/tests/unit/services/test_platform_service.py b/tests/unit/services/test_platform_service.py new file mode 100644 index 0000000..00b7fea --- /dev/null +++ b/tests/unit/services/test_platform_service.py @@ -0,0 +1,231 @@ +"""Unit tests for PlatformService.""" + +import sys +from collections.abc import Generator +from contextlib import contextmanager +from io import StringIO +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest +from _pytest.monkeypatch import MonkeyPatch + +from solc_select.models.artifacts import SolcArtifactOnDisk +from solc_select.models.platforms import Platform +from solc_select.models.versions import SolcVersion +from solc_select.platform_capabilities import EmulationCapability, PlatformIdentifier +from solc_select.services.platform_service import PlatformService + + +@contextmanager +def capture_stderr() -> Generator[StringIO, None, None]: + """Context manager to capture stderr output.""" + capture = StringIO() + original = sys.stderr + sys.stderr = capture + try: + yield capture + finally: + sys.stderr = original + + +@pytest.fixture +def darwin_arm64_platform() -> Platform: + """Platform instance for macOS ARM64.""" + return Platform("darwin", "arm64") + + +@pytest.fixture +def linux_arm64_platform() -> Platform: + """Platform instance for Linux ARM64.""" + return Platform("linux", "arm64") + + +@pytest.fixture +def linux_amd64_platform() -> Platform: + """Platform instance for Linux AMD64.""" + return Platform("linux", "amd64") + + +@pytest.fixture +def native_artifact(linux_amd64_platform: Platform, tmp_path: Path) -> SolcArtifactOnDisk: + """Native artifact without emulation.""" + return SolcArtifactOnDisk( + version=SolcVersion("0.8.19"), + platform=linux_amd64_platform, + file_path=tmp_path / "solc-v0.8.19", + emulation=None, + ) + + +def create_emulated_artifact( + platform: Platform, tmp_path: Path, emulation_type: str, detector_available: bool = True +) -> SolcArtifactOnDisk: + """Create an emulated artifact for testing.""" + if emulation_type == "qemu": + target = PlatformIdentifier("linux", "amd64") + prefix = ["qemu-x86_64"] + else: # rosetta + target = PlatformIdentifier("darwin", "amd64") + prefix = [] # Rosetta is transparent + + emulation = EmulationCapability( + target_platform=target, + emulation_type=emulation_type, + detector=Mock(return_value=detector_available), + command_prefix=prefix, + ) + return SolcArtifactOnDisk( + version=SolcVersion("0.8.19"), + platform=platform, + file_path=tmp_path / "solc-v0.8.19", + emulation=emulation, + ) + + +class TestPlatformServiceEmulation: + """Tests for emulation prefix retrieval.""" + + def test_get_emulation_prefix_native( + self, linux_amd64_platform: Platform, native_artifact: SolcArtifactOnDisk + ) -> None: + """Returns empty list for native binary.""" + service = PlatformService(linux_amd64_platform) + assert service.get_emulation_prefix(native_artifact) == [] + + def test_get_emulation_prefix_qemu_available( + self, linux_arm64_platform: Platform, tmp_path: Path + ) -> None: + """Returns ['qemu-x86_64'] when QEMU available.""" + service = PlatformService(linux_arm64_platform) + artifact = create_emulated_artifact(linux_arm64_platform, tmp_path, "qemu") + assert service.get_emulation_prefix(artifact) == ["qemu-x86_64"] + + def test_get_emulation_prefix_emulator_unavailable( + self, linux_arm64_platform: Platform, tmp_path: Path + ) -> None: + """Raises RuntimeError when emulator is not available.""" + service = PlatformService(linux_arm64_platform) + artifact = create_emulated_artifact( + linux_arm64_platform, tmp_path, "qemu", detector_available=False + ) + + with pytest.raises(RuntimeError) as exc_info: + service.get_emulation_prefix(artifact) + + error_msg = str(exc_info.value).lower() + assert "qemu" in error_msg + assert "not available" in error_msg + + def test_get_emulation_prefix_rosetta( + self, darwin_arm64_platform: Platform, tmp_path: Path + ) -> None: + """Returns empty list for Rosetta (transparent emulation).""" + service = PlatformService(darwin_arm64_platform) + artifact = create_emulated_artifact(darwin_arm64_platform, tmp_path, "rosetta") + assert service.get_emulation_prefix(artifact) == [] + + +class TestPlatformServiceWarnings: + """Tests for ARM64 compatibility warnings.""" + + def test_warn_arm64_skips_on_amd64(self, linux_amd64_platform: Platform) -> None: + """No warning shown on non-ARM64 platforms.""" + service = PlatformService(linux_amd64_platform) + with capture_stderr() as output: + service.warn_about_arm64_compatibility() + assert output.getvalue() == "" + + def test_warn_arm64_shown_once_with_marker( + self, linux_arm64_platform: Platform, temp_solc_select_dir: Path, monkeypatch: MonkeyPatch + ) -> None: + """Warning shown once, creates marker file, not shown again.""" + monkeypatch.setattr( + "solc_select.services.platform_service.SOLC_SELECT_DIR", temp_solc_select_dir + ) + + with patch("solc_select.services.platform_service.detect_qemu", return_value=False): + service = PlatformService(linux_arm64_platform) + + # First call shows warning + with capture_stderr() as first: + service.warn_about_arm64_compatibility() + assert "WARNING: ARM64 Architecture Detected" in first.getvalue() + assert (temp_solc_select_dir / ".arm64_warning_shown").exists() + + # Second call is silent + with capture_stderr() as second: + service.warn_about_arm64_compatibility() + assert second.getvalue() == "" + + # force=True shows again + with capture_stderr() as forced: + service.warn_about_arm64_compatibility(force=True) + assert "WARNING: ARM64 Architecture Detected" in forced.getvalue() + + @pytest.mark.parametrize( + "os_type,detector_name,detector_value,expected_phrases,unexpected_phrases", + [ + # Darwin with Rosetta available + ( + "darwin", + "detect_rosetta", + True, + ["Rosetta 2 detected", "will use emulation for older versions"], + ["To use solc-select on ARM64"], + ), + # Darwin without Rosetta + ( + "darwin", + "detect_rosetta", + False, + ["Rosetta 2 not available", "versions prior to 0.8.5 are x86_64 only"], + [], + ), + # Linux with QEMU available + ( + "linux", + "detect_qemu", + True, + ["qemu-x86_64 detected", "will use emulation for versions < 0.8.31"], + ["To use solc-select on ARM64"], + ), + # Linux without QEMU + ( + "linux", + "detect_qemu", + False, + ["Versions < 0.8.31 require x86_64 emulation", "qemu is not installed"], + [], + ), + ], + ) + def test_warn_arm64_platform_specific( + self, + temp_solc_select_dir: Path, + monkeypatch: MonkeyPatch, + os_type: str, + detector_name: str, + detector_value: bool, + expected_phrases: list[str], + unexpected_phrases: list[str], + ) -> None: + """Test platform-specific warning messages.""" + platform = Platform(os_type, "arm64") + monkeypatch.setattr( + "solc_select.services.platform_service.SOLC_SELECT_DIR", temp_solc_select_dir + ) + + with patch( + f"solc_select.services.platform_service.{detector_name}", return_value=detector_value + ): + service = PlatformService(platform) + with capture_stderr() as output: + service.warn_about_arm64_compatibility() + result = output.getvalue() + + assert "WARNING: ARM64 Architecture Detected" in result + for phrase in expected_phrases: + assert phrase in result + for phrase in unexpected_phrases: + assert phrase not in result diff --git a/tests/unit/services/test_repository_matcher.py b/tests/unit/services/test_repository_matcher.py new file mode 100644 index 0000000..c3cce52 --- /dev/null +++ b/tests/unit/services/test_repository_matcher.py @@ -0,0 +1,669 @@ +"""Unit tests for RepositoryMatcher service.""" + +from unittest.mock import Mock, PropertyMock + +import pytest +import requests + +from solc_select.exceptions import VersionNotFoundError +from solc_select.models.repositories import PlatformSupport, RepositoryManifest +from solc_select.models.versions import SolcVersion, VersionRange +from solc_select.platform_capabilities import PlatformCapability, PlatformIdentifier +from solc_select.repositories import SolcRepository +from solc_select.services.repository_matcher import RepositoryMatcher + +# Platform identifiers as module-level constants to reduce fixture overhead +LINUX_AMD64 = PlatformIdentifier("linux", "amd64") +LINUX_ARM64 = PlatformIdentifier("linux", "arm64") +DARWIN_ARM64 = PlatformIdentifier("darwin", "arm64") +DARWIN_AMD64 = PlatformIdentifier("darwin", "amd64") + + +@pytest.fixture +def linux_amd64_platform() -> PlatformIdentifier: + """Linux AMD64 platform identifier.""" + return LINUX_AMD64 + + +@pytest.fixture +def linux_arm64_platform() -> PlatformIdentifier: + """Linux ARM64 platform identifier.""" + return LINUX_ARM64 + + +@pytest.fixture +def darwin_arm64_platform() -> PlatformIdentifier: + """Darwin ARM64 platform identifier.""" + return DARWIN_ARM64 + + +@pytest.fixture +def darwin_amd64_platform() -> PlatformIdentifier: + """Darwin AMD64 platform identifier.""" + return DARWIN_AMD64 + + +@pytest.fixture +def soliditylang_manifest( + linux_amd64_platform: PlatformIdentifier, linux_arm64_platform: PlatformIdentifier +) -> RepositoryManifest: + """Soliditylang repository manifest.""" + return RepositoryManifest( + repository_id="soliditylang", + base_url="https://binaries.soliditylang.org", + platform_supports=[ + PlatformSupport( + platform=linux_amd64_platform, + version_range=VersionRange.from_min("0.4.10"), + ), + PlatformSupport( + platform=linux_arm64_platform, + version_range=VersionRange.from_min("0.8.31"), + ), + ], + priority=100, + ) + + +@pytest.fixture +def alloy_manifest(darwin_arm64_platform: PlatformIdentifier) -> RepositoryManifest: + """Alloy repository manifest.""" + return RepositoryManifest( + repository_id="alloy", + base_url="https://raw.githubusercontent.com/alloy-rs/solc-builds/main/", + platform_supports=[ + PlatformSupport( + platform=darwin_arm64_platform, + version_range=VersionRange.exact_range("0.8.5", "0.8.23"), + ), + ], + priority=90, + ) + + +@pytest.fixture +def crytic_manifest(linux_amd64_platform: PlatformIdentifier) -> RepositoryManifest: + """Crytic repository manifest.""" + return RepositoryManifest( + repository_id="crytic", + base_url="https://raw.githubusercontent.com/crytic/solc/master/", + platform_supports=[ + PlatformSupport( + platform=linux_amd64_platform, + version_range=VersionRange.exact_range("0.4.0", "0.4.10"), + ), + ], + priority=10, + ) + + +@pytest.fixture +def mock_platform_capability(linux_amd64_platform: PlatformIdentifier) -> Mock: + """Mock platform capability for Linux AMD64 (native only).""" + mock_cap = Mock(spec=PlatformCapability) + mock_cap.host_platform = linux_amd64_platform + mock_cap.native_support = linux_amd64_platform + mock_cap.get_runnable_platforms.return_value = [linux_amd64_platform] + return mock_cap + + +@pytest.fixture +def mock_platform_capability_with_emulation( + darwin_arm64_platform: PlatformIdentifier, darwin_amd64_platform: PlatformIdentifier +) -> Mock: + """Mock platform capability for Darwin ARM64 with x86 emulation.""" + mock_cap = Mock(spec=PlatformCapability) + mock_cap.host_platform = darwin_arm64_platform + mock_cap.native_support = darwin_arm64_platform + # Native first, then emulated + mock_cap.get_runnable_platforms.return_value = [darwin_arm64_platform, darwin_amd64_platform] + return mock_cap + + +@pytest.fixture +def mock_repository() -> Mock: + """Mock SolcRepository with standard versions.""" + mock_repo = Mock(spec=SolcRepository) + type(mock_repo).available_versions = PropertyMock( + return_value={ + "0.8.19": "solc-linux-amd64-v0.8.19+commit.abc123", + "0.8.20": "solc-linux-amd64-v0.8.20+commit.def456", + "0.8.21": "solc-linux-amd64-v0.8.21+commit.ghi789", + } + ) + return mock_repo + + +class TestRepositoryMatcherFind: + """Tests for find_repository_for_version method.""" + + def test_find_native_binary_preferred( + self, + mock_session, + mock_platform_capability_with_emulation, + soliditylang_manifest, + alloy_manifest, + darwin_arm64_platform, + ): + """Native platform binaries should be selected over emulated binaries.""" + # Soliditylang has native darwin-arm64 support for 0.8.24+ + soliditylang_manifest.platform_supports.append( + PlatformSupport( + platform=darwin_arm64_platform, + version_range=VersionRange.from_min("0.8.24"), + ) + ) + + matcher = RepositoryMatcher( + platform_capability=mock_platform_capability_with_emulation, + manifests=[soliditylang_manifest, alloy_manifest], + session=mock_session, + ) + + # Version 0.8.25 is available natively on darwin-arm64 (soliditylang) + version = SolcVersion.parse("0.8.25") + repo, target_platform = matcher.find_repository_for_version(version, exact=False) + + # Should select native darwin-arm64 from soliditylang + assert target_platform == darwin_arm64_platform + assert "soliditylang" in str(type(repo).__name__).lower() or hasattr( + repo, "base_url" + ) # Check it's soliditylang repo + + def test_find_emulated_binary_fallback( + self, + mock_session, + mock_platform_capability_with_emulation, + alloy_manifest, + darwin_arm64_platform, + darwin_amd64_platform, + ): + """Should use emulated binaries when native ones are unavailable.""" + # Alloy only provides darwin-arm64 for 0.8.5-0.8.23 + # For 0.8.4, should fall back to darwin-amd64 (emulated) + + # Add darwin-amd64 support to alloy manifest for this version + alloy_manifest.platform_supports.append( + PlatformSupport( + platform=darwin_amd64_platform, + version_range=VersionRange.from_min("0.4.0"), + ) + ) + + matcher = RepositoryMatcher( + platform_capability=mock_platform_capability_with_emulation, + manifests=[alloy_manifest], + session=mock_session, + ) + + version = SolcVersion.parse("0.8.4") + _repo, target_platform = matcher.find_repository_for_version(version, exact=False) + + # Should fall back to emulated darwin-amd64 + assert target_platform == darwin_amd64_platform + + def test_find_respects_priority_order( + self, + mock_session, + mock_platform_capability, + soliditylang_manifest, + crytic_manifest, + linux_amd64_platform, + ): + """Higher priority manifest should win when multiple repos provide the version.""" + # Both soliditylang (priority 100) and crytic (priority 10) provide 0.4.10 + matcher = RepositoryMatcher( + platform_capability=mock_platform_capability, + manifests=[soliditylang_manifest, crytic_manifest], + session=mock_session, + ) + + version = SolcVersion.parse("0.4.10") + repo, target_platform = matcher.find_repository_for_version(version, exact=False) + + # Soliditylang has higher priority (100 > 10) + assert target_platform == linux_amd64_platform + # Check it's from soliditylang repo by checking the base_url or list_url + assert hasattr(repo, "base_url") + assert "binaries.soliditylang.org" in repo.base_url + + def test_find_version_not_found( + self, + mock_session, + mock_platform_capability, + alloy_manifest, + ): + """Should raise VersionNotFoundError when no repository provides the version.""" + matcher = RepositoryMatcher( + platform_capability=mock_platform_capability, + manifests=[alloy_manifest], # Only has darwin-arm64 support + session=mock_session, + ) + + # Linux AMD64 is requested but alloy only has darwin-arm64 + version = SolcVersion.parse("0.8.19") + + with pytest.raises(VersionNotFoundError) as exc_info: + matcher.find_repository_for_version(version, exact=False) + + assert "0.8.19" in str(exc_info.value) + assert "linux-amd64" in str(exc_info.value) + + def test_find_platform_not_supported( + self, + mock_session, + mock_platform_capability, + soliditylang_manifest, + linux_amd64_platform, + ): + """Should raise VersionNotFoundError when version is outside platform's range.""" + # Soliditylang linux-amd64 starts at 0.4.10 + matcher = RepositoryMatcher( + platform_capability=mock_platform_capability, + manifests=[soliditylang_manifest], + session=mock_session, + ) + + version = SolcVersion.parse("0.4.5") # Below minimum 0.4.10 + + with pytest.raises(VersionNotFoundError) as exc_info: + matcher.find_repository_for_version(version, exact=False) + + assert "0.4.5" in str(exc_info.value) + + def test_find_exact_false_skips_check( + self, + mock_session, + mock_platform_capability, + soliditylang_manifest, + linux_amd64_platform, + ): + """With exact=False, should not verify version exists in repository.""" + matcher = RepositoryMatcher( + platform_capability=mock_platform_capability, + manifests=[soliditylang_manifest], + session=mock_session, + ) + + version = SolcVersion.parse("0.8.19") + + # exact=False should skip checking available_versions + _repo, target_platform = matcher.find_repository_for_version(version, exact=False) + + assert target_platform == linux_amd64_platform + # Should not have accessed available_versions property + # (no assertion on repo property access since it's mocked differently) + + def test_find_exact_true_verifies_availability( + self, + mock_session, + mock_platform_capability, + soliditylang_manifest, + linux_amd64_platform, + ): + """With exact=True, should verify version exists in repository.""" + matcher = RepositoryMatcher( + platform_capability=mock_platform_capability, + manifests=[soliditylang_manifest], + session=mock_session, + ) + + # Mock the repository to have specific versions + key = ("soliditylang", str(linux_amd64_platform)) + mock_repo = Mock(spec=SolcRepository) + type(mock_repo).available_versions = PropertyMock( + return_value={ + "0.8.19": "solc-linux-amd64-v0.8.19+commit.abc123", + } + ) + matcher.repositories[key] = mock_repo + + # This version exists + version = SolcVersion.parse("0.8.19") + _repo, target_platform = matcher.find_repository_for_version(version, exact=True) + assert target_platform == linux_amd64_platform + + # This version doesn't exist + version_not_available = SolcVersion.parse("0.8.99") + with pytest.raises(VersionNotFoundError): + matcher.find_repository_for_version(version_not_available, exact=True) + + def test_find_multiple_manifests_same_priority( + self, + mock_session, + mock_platform_capability, + linux_amd64_platform, + ): + """When multiple manifests have same priority, first match should win.""" + # Use real repository IDs to avoid ValueError + manifest1 = RepositoryManifest( + repository_id="soliditylang", + base_url="https://repo1.example.com", + platform_supports=[ + PlatformSupport( + platform=linux_amd64_platform, + version_range=VersionRange.from_min("0.4.0"), + ), + ], + priority=50, + ) + + manifest2 = RepositoryManifest( + repository_id="crytic", + base_url="https://repo2.example.com", + platform_supports=[ + PlatformSupport( + platform=linux_amd64_platform, + version_range=VersionRange.from_min("0.4.0"), + ), + ], + priority=50, + ) + + # Create matcher with known order + matcher = RepositoryMatcher( + platform_capability=mock_platform_capability, + manifests=[manifest1, manifest2], + session=mock_session, + ) + + version = SolcVersion.parse("0.8.19") + + # Should pick first one (manifest1) since they have same priority + repo, target_platform = matcher.find_repository_for_version(version, exact=False) + + # Verify it came from the first manifest + assert target_platform == linux_amd64_platform + # Check it's from first repo (soliditylang) by checking base_url matches manifest1 + assert hasattr(repo, "base_url") + assert "binaries.soliditylang.org" in repo.base_url # Real soliditylang URL + + +class TestRepositoryMatcherAggregation: + """Tests for get_all_available_versions method.""" + + def test_get_all_versions_multiple_repos( + self, + mock_session, + mock_platform_capability, + soliditylang_manifest, + crytic_manifest, + linux_amd64_platform, + ): + """Should combine versions from all repositories.""" + matcher = RepositoryMatcher( + platform_capability=mock_platform_capability, + manifests=[soliditylang_manifest, crytic_manifest], + session=mock_session, + ) + + # Mock repositories with different version sets + soliditylang_key = ("soliditylang", str(linux_amd64_platform)) + soliditylang_repo = Mock(spec=SolcRepository) + type(soliditylang_repo).available_versions = PropertyMock( + return_value={ + "0.8.19": "solc-linux-amd64-v0.8.19+commit.abc123", + "0.8.20": "solc-linux-amd64-v0.8.20+commit.def456", + } + ) + matcher.repositories[soliditylang_key] = soliditylang_repo + + crytic_key = ("crytic", str(linux_amd64_platform)) + crytic_repo = Mock(spec=SolcRepository) + type(crytic_repo).available_versions = PropertyMock( + return_value={ + "0.4.5": "solc-v0.4.5", + "0.4.6": "solc-v0.4.6", + } + ) + matcher.repositories[crytic_key] = crytic_repo + + all_versions = matcher.get_all_available_versions() + + # Should have versions from both repos + assert SolcVersion.parse("0.8.19") in all_versions + assert SolcVersion.parse("0.8.20") in all_versions + assert SolcVersion.parse("0.4.5") in all_versions + assert SolcVersion.parse("0.4.6") in all_versions + assert len(all_versions) == 4 + + def test_get_all_versions_handles_network_errors( + self, + mock_session, + mock_platform_capability, + soliditylang_manifest, + crytic_manifest, + linux_amd64_platform, + ): + """Should continue on repository failure and return available versions.""" + matcher = RepositoryMatcher( + platform_capability=mock_platform_capability, + manifests=[soliditylang_manifest, crytic_manifest], + session=mock_session, + ) + + # First repo works + soliditylang_key = ("soliditylang", str(linux_amd64_platform)) + soliditylang_repo = Mock(spec=SolcRepository) + type(soliditylang_repo).available_versions = PropertyMock( + return_value={ + "0.8.19": "solc-linux-amd64-v0.8.19+commit.abc123", + } + ) + matcher.repositories[soliditylang_key] = soliditylang_repo + + # Second repo fails with network error + crytic_key = ("crytic", str(linux_amd64_platform)) + crytic_repo = Mock(spec=SolcRepository) + type(crytic_repo).available_versions = PropertyMock( + side_effect=requests.RequestException("Network error") + ) + matcher.repositories[crytic_key] = crytic_repo + + all_versions = matcher.get_all_available_versions() + + # Should have versions from working repo only + assert SolcVersion.parse("0.8.19") in all_versions + assert len(all_versions) == 1 + + def test_get_all_versions_deduplicates( + self, + mock_session, + mock_platform_capability, + linux_amd64_platform, + ): + """Should not duplicate same version from multiple repos.""" + # Create new manifests that both support 0.8.19 (can't modify frozen dataclass) + soliditylang_manifest_new = RepositoryManifest( + repository_id="soliditylang", + base_url="https://binaries.soliditylang.org", + platform_supports=[ + PlatformSupport( + platform=linux_amd64_platform, + version_range=VersionRange.from_min("0.4.10"), + ), + ], + priority=100, + ) + + crytic_manifest_new = RepositoryManifest( + repository_id="crytic", + base_url="https://raw.githubusercontent.com/crytic/solc/master/", + platform_supports=[ + PlatformSupport( + platform=linux_amd64_platform, + version_range=VersionRange.from_min("0.4.0"), # Extended to support 0.8.19 + ), + ], + priority=10, + ) + + matcher = RepositoryMatcher( + platform_capability=mock_platform_capability, + manifests=[soliditylang_manifest_new, crytic_manifest_new], + session=mock_session, + ) + + # Both repos provide 0.8.19 + soliditylang_key = ("soliditylang", str(linux_amd64_platform)) + soliditylang_repo = Mock(spec=SolcRepository) + type(soliditylang_repo).available_versions = PropertyMock( + return_value={ + "0.8.19": "solc-linux-amd64-v0.8.19+commit.abc123", + } + ) + matcher.repositories[soliditylang_key] = soliditylang_repo + + crytic_key = ("crytic", str(linux_amd64_platform)) + crytic_repo = Mock(spec=SolcRepository) + type(crytic_repo).available_versions = PropertyMock( + return_value={ + "0.8.19": "solc-v0.8.19", + } + ) + matcher.repositories[crytic_key] = crytic_repo + + all_versions = matcher.get_all_available_versions() + + # Should only have one entry for 0.8.19 + version_list = [v for v in all_versions if v == SolcVersion.parse("0.8.19")] + assert len(version_list) == 1 + assert len(all_versions) == 1 + + # Should be from higher priority repo (soliditylang) + manifest, _platform = all_versions[SolcVersion.parse("0.8.19")] + assert manifest.repository_id == "soliditylang" + + def test_get_all_versions_filters_by_platform( + self, + mock_session, + mock_platform_capability, + soliditylang_manifest, + linux_amd64_platform, + ): + """Should only return versions supported by the platform.""" + matcher = RepositoryMatcher( + platform_capability=mock_platform_capability, + manifests=[soliditylang_manifest], + session=mock_session, + ) + + # Mock repository with versions both inside and outside range + soliditylang_key = ("soliditylang", str(linux_amd64_platform)) + soliditylang_repo = Mock(spec=SolcRepository) + type(soliditylang_repo).available_versions = PropertyMock( + return_value={ + "0.4.9": "solc-v0.4.9", # Below minimum 0.4.10 + "0.4.10": "solc-v0.4.10", # At minimum + "0.8.19": "solc-v0.8.19", # Above minimum + } + ) + matcher.repositories[soliditylang_key] = soliditylang_repo + + all_versions = matcher.get_all_available_versions() + + # Should only have 0.4.10+ (minimum for linux-amd64) + assert SolcVersion.parse("0.4.9") not in all_versions + assert SolcVersion.parse("0.4.10") in all_versions + assert SolcVersion.parse("0.8.19") in all_versions + assert len(all_versions) == 2 + + def test_get_all_versions_empty_repos( + self, + mock_session, + mock_platform_capability, + soliditylang_manifest, + linux_amd64_platform, + ): + """Should return empty dict when repositories have no versions.""" + matcher = RepositoryMatcher( + platform_capability=mock_platform_capability, + manifests=[soliditylang_manifest], + session=mock_session, + ) + + # Mock repository with no versions + soliditylang_key = ("soliditylang", str(linux_amd64_platform)) + soliditylang_repo = Mock(spec=SolcRepository) + type(soliditylang_repo).available_versions = PropertyMock(return_value={}) + matcher.repositories[soliditylang_key] = soliditylang_repo + + all_versions = matcher.get_all_available_versions() + + assert len(all_versions) == 0 + assert all_versions == {} + + +class TestRepositoryMatcherFactory: + """Tests for _create_repository factory method.""" + + @pytest.mark.parametrize( + "manifest_fixture,platform_fixture,capability_fixture", + [ + ("soliditylang_manifest", "linux_amd64_platform", "mock_platform_capability"), + ("crytic_manifest", "linux_amd64_platform", "mock_platform_capability"), + ("alloy_manifest", "darwin_arm64_platform", "mock_platform_capability_with_emulation"), + ], + ) + def test_create_repository( + self, mock_session, manifest_fixture, platform_fixture, capability_fixture, request + ): + """Repository factory creates correct instances with session attached.""" + manifest = request.getfixturevalue(manifest_fixture) + platform = request.getfixturevalue(platform_fixture) + capability = request.getfixturevalue(capability_fixture) + + matcher = RepositoryMatcher( + platform_capability=capability, + manifests=[manifest], + session=mock_session, + ) + + key = (manifest.repository_id, str(platform)) + repo = matcher.repositories[key] + + assert repo is not None + assert hasattr(repo, "session") + assert repo.session is mock_session + + def test_create_repository_unknown_id(self, mock_session, mock_platform_capability): + """Should raise ValueError for unknown repository ID.""" + unknown_manifest = RepositoryManifest( + repository_id="unknown_repo", + base_url="https://unknown.example.com", + platform_supports=[ + PlatformSupport(platform=LINUX_AMD64, version_range=VersionRange.from_min("0.4.0")), + ], + priority=50, + ) + + with pytest.raises(ValueError, match="Unknown repository: unknown_repo"): + RepositoryMatcher( + platform_capability=mock_platform_capability, + manifests=[unknown_manifest], + session=mock_session, + ) + + def test_create_repository_multiple_platforms(self, mock_session, soliditylang_manifest): + """Creates separate repository instances for each platform.""" + mock_cap = Mock(spec=PlatformCapability) + mock_cap.host_platform = LINUX_AMD64 + mock_cap.native_support = LINUX_AMD64 + mock_cap.get_runnable_platforms.return_value = [LINUX_AMD64, LINUX_ARM64] + + soliditylang_manifest.platform_supports.append( + PlatformSupport(platform=LINUX_ARM64, version_range=VersionRange.from_min("0.8.31")) + ) + + matcher = RepositoryMatcher( + platform_capability=mock_cap, + manifests=[soliditylang_manifest], + session=mock_session, + ) + + amd64_key = ("soliditylang", str(LINUX_AMD64)) + arm64_key = ("soliditylang", str(LINUX_ARM64)) + + assert amd64_key in matcher.repositories + assert arm64_key in matcher.repositories + assert matcher.repositories[amd64_key] is not matcher.repositories[arm64_key] diff --git a/tests/unit/services/test_solc_service.py b/tests/unit/services/test_solc_service.py new file mode 100644 index 0000000..9220e65 --- /dev/null +++ b/tests/unit/services/test_solc_service.py @@ -0,0 +1,559 @@ +"""Unit tests for SolcService.""" + +import subprocess +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest + +from solc_select.exceptions import ( + InstallationError, + NoVersionSetError, + VersionNotFoundError, + VersionNotInstalledError, +) +from solc_select.models.platforms import Platform +from solc_select.models.versions import SolcVersion +from solc_select.services.solc_service import SolcService + + +@pytest.fixture +def mock_dependencies(): + """Create mocked dependencies for SolcService.""" + return { + "filesystem": Mock(), + "version_manager": Mock(), + "artifact_manager": Mock(), + "platform_service": Mock(), + "repository_matcher": Mock(), + } + + +@pytest.fixture +def arm64_platform(): + """Create an ARM64 platform for testing.""" + return Platform("linux", "arm64") + + +@pytest.fixture +def amd64_platform(): + """Create an AMD64 platform for testing.""" + return Platform("linux", "amd64") + + +@pytest.fixture +def solc_service(mock_platform, mock_dependencies, monkeypatch): + """Create SolcService with mocked dependencies.""" + # Create service (this will create real dependencies initially) + service = SolcService(platform=mock_platform) + + # Replace dependencies with mocks + service.filesystem = mock_dependencies["filesystem"] + service.version_manager = mock_dependencies["version_manager"] + service.artifact_manager = mock_dependencies["artifact_manager"] + service.platform_service = mock_dependencies["platform_service"] + service.repository_matcher = mock_dependencies["repository_matcher"] + + return service + + +class TestSolcServiceVersion: + """Tests for version querying operations.""" + + def test_get_current_version_success(self, solc_service, mock_dependencies): + """Returns version and source when version is set and installed.""" + version = SolcVersion("0.8.19") + mock_dependencies["filesystem"].get_current_version.return_value = version + mock_dependencies["filesystem"].get_version_source.return_value = "SOLC_VERSION env var" + mock_dependencies["filesystem"].is_installed.return_value = True + + result_version, result_source = solc_service.get_current_version() + + assert result_version == version + assert result_source == "SOLC_VERSION env var" + mock_dependencies["filesystem"].get_current_version.assert_called_once() + mock_dependencies["filesystem"].get_version_source.assert_called_once() + mock_dependencies["filesystem"].is_installed.assert_called_once_with(version) + + def test_get_current_version_none_set(self, solc_service, mock_dependencies): + """Raises NoVersionSetError when no version is set.""" + mock_dependencies["filesystem"].get_current_version.return_value = None + + with pytest.raises(NoVersionSetError) as exc_info: + solc_service.get_current_version() + + assert "No solc version set" in str(exc_info.value) + + def test_get_current_version_not_installed(self, solc_service, mock_dependencies): + """Raises VersionNotInstalledError when version is set but not installed.""" + version = SolcVersion("0.8.19") + installed = [SolcVersion("0.8.20"), SolcVersion("0.8.21")] + mock_dependencies["filesystem"].get_current_version.return_value = version + mock_dependencies[ + "filesystem" + ].get_version_source.return_value = "~/.solc-select/global-version" + mock_dependencies["filesystem"].is_installed.return_value = False + mock_dependencies["filesystem"].get_installed_versions.return_value = installed + + with pytest.raises(VersionNotInstalledError) as exc_info: + solc_service.get_current_version() + + assert "0.8.19" in str(exc_info.value) + assert "is not installed" in str(exc_info.value) + assert exc_info.value.version == "0.8.19" + assert exc_info.value.installed_versions == ["0.8.20", "0.8.21"] + assert exc_info.value.source == "~/.solc-select/global-version" + + def test_get_installed_versions(self, solc_service, mock_dependencies): + """Returns list of installed versions from filesystem.""" + installed = [SolcVersion("0.8.19"), SolcVersion("0.8.20"), SolcVersion("0.8.21")] + mock_dependencies["filesystem"].get_installed_versions.return_value = installed + + result = solc_service.get_installed_versions() + + assert result == installed + mock_dependencies["filesystem"].get_installed_versions.assert_called_once() + + +class TestSolcServiceSwitch: + """Tests for switching global version.""" + + @pytest.mark.parametrize( + "silent,expected_output", [(False, "Switched global version"), (True, "")] + ) + def test_switch_already_installed( + self, solc_service, mock_dependencies, capsys, silent, expected_output + ): + """Switches immediately when version is already installed.""" + version = SolcVersion("0.8.19") + mock_dependencies["version_manager"].validate_version.return_value = version + mock_dependencies["filesystem"].is_installed.return_value = True + + solc_service.switch_global_version("0.8.19", silent=silent) + + mock_dependencies["filesystem"].set_global_version.assert_called_once_with(version) + captured = capsys.readouterr() + if expected_output: + assert expected_output in captured.out + else: + assert captured.out == "" + + def test_switch_not_installed_no_auto_install(self, solc_service, mock_dependencies): + """Raises VersionNotInstalledError when version not installed and auto_install=False.""" + version = SolcVersion("0.8.19") + available = [SolcVersion("0.8.19"), SolcVersion("0.8.20")] + mock_dependencies["version_manager"].validate_version.return_value = version + mock_dependencies["filesystem"].is_installed.return_value = False + mock_dependencies["version_manager"].get_available_versions.return_value = available + + with pytest.raises(VersionNotInstalledError) as exc_info: + solc_service.switch_global_version("0.8.19") + + assert "0.8.19" in str(exc_info.value) + assert "is not installed" in str(exc_info.value) + + def test_switch_not_installed_with_auto_install(self, solc_service, mock_dependencies, capsys): + """Installs then switches when always_install=True.""" + version = SolcVersion("0.8.19") + mock_dependencies["version_manager"].validate_version.return_value = version + # First call: not installed, second call: installed + mock_dependencies["filesystem"].is_installed.side_effect = [False, True] + mock_dependencies["version_manager"].resolve_version_strings.return_value = [version] + mock_dependencies["version_manager"].get_available_versions.return_value = [version] + mock_dependencies["artifact_manager"].install_versions.return_value = True + + solc_service.switch_global_version("0.8.19", always_install=True) + + # Should call install_versions with the version (silent=False for internal call) + mock_dependencies["version_manager"].resolve_version_strings.assert_called_once_with( + ["0.8.19"] + ) + mock_dependencies["artifact_manager"].install_versions.assert_called_once_with( + [version], False + ) + # Should set global version after installation + mock_dependencies["filesystem"].set_global_version.assert_called_once_with(version) + captured = capsys.readouterr() + assert "Switched global version to 0.8.19" in captured.out + + def test_switch_invalid_version(self, solc_service, mock_dependencies): + """Raises VersionNotFoundError when version is invalid.""" + available = [ + SolcVersion("0.8.19"), + SolcVersion("0.8.20"), + SolcVersion("0.8.21"), + SolcVersion("0.8.22"), + SolcVersion("0.8.23"), + ] + mock_dependencies["version_manager"].validate_version.side_effect = VersionNotFoundError( + "0.8.99", [str(v) for v in available] + ) + + with pytest.raises(VersionNotFoundError) as exc_info: + solc_service.switch_global_version("0.8.99") + + assert "0.8.99" in str(exc_info.value) + + def test_switch_installation_fails(self, solc_service, mock_dependencies): + """Raises InstallationError when installation fails with always_install=True.""" + version = SolcVersion("0.8.19") + mock_dependencies["version_manager"].validate_version.return_value = version + mock_dependencies["filesystem"].is_installed.return_value = False + mock_dependencies["version_manager"].resolve_version_strings.return_value = [version] + mock_dependencies["version_manager"].get_available_versions.return_value = [version] + mock_dependencies["artifact_manager"].install_versions.return_value = False + + with pytest.raises(InstallationError) as exc_info: + solc_service.switch_global_version("0.8.19", always_install=True) + + assert "0.8.19" in str(exc_info.value) + assert "Installation failed" in str(exc_info.value) + + def test_switch_not_available_raises_version_not_found(self, solc_service, mock_dependencies): + """Raises VersionNotFoundError when version is not available at all.""" + version = SolcVersion("0.4.3") + available = [ + SolcVersion("0.8.19"), + SolcVersion("0.8.20"), + SolcVersion("0.8.21"), + SolcVersion("0.8.22"), + SolcVersion("0.8.23"), + ] + mock_dependencies["version_manager"].validate_version.return_value = version + mock_dependencies["filesystem"].is_installed.return_value = False + mock_dependencies["version_manager"].get_available_versions.return_value = available + + with pytest.raises(VersionNotFoundError) as exc_info: + solc_service.switch_global_version("0.4.3") + + assert "0.4.3" in str(exc_info.value) + # Should show first 5 available versions + assert exc_info.value.available_versions == [ + "0.8.19", + "0.8.20", + "0.8.21", + "0.8.22", + "0.8.23", + ] + + +class TestSolcServiceInstall: + """Tests for installing versions.""" + + def test_install_versions_shows_arm64_warning(self, arm64_platform, mock_dependencies): + """Calls PlatformService.warn_about_arm64_compatibility for ARM64.""" + # Create service with ARM64 platform + service = SolcService(platform=arm64_platform) + service.filesystem = mock_dependencies["filesystem"] + service.version_manager = mock_dependencies["version_manager"] + service.artifact_manager = mock_dependencies["artifact_manager"] + service.platform_service = mock_dependencies["platform_service"] + + versions = [SolcVersion("0.8.19")] + mock_dependencies["version_manager"].resolve_version_strings.return_value = versions + mock_dependencies["version_manager"].get_available_versions.return_value = versions + mock_dependencies["artifact_manager"].install_versions.return_value = True + + service.install_versions(["0.8.19"]) + + mock_dependencies["platform_service"].warn_about_arm64_compatibility.assert_called_once() + + def test_install_versions_no_warning_for_amd64(self, amd64_platform, mock_dependencies): + """Does not show warning for non-ARM64 platforms.""" + # Create service with AMD64 platform + service = SolcService(platform=amd64_platform) + service.filesystem = mock_dependencies["filesystem"] + service.version_manager = mock_dependencies["version_manager"] + service.artifact_manager = mock_dependencies["artifact_manager"] + service.platform_service = mock_dependencies["platform_service"] + + versions = [SolcVersion("0.8.19")] + mock_dependencies["version_manager"].resolve_version_strings.return_value = versions + mock_dependencies["version_manager"].get_available_versions.return_value = versions + mock_dependencies["artifact_manager"].install_versions.return_value = True + + service.install_versions(["0.8.19"]) + + mock_dependencies["platform_service"].warn_about_arm64_compatibility.assert_not_called() + + def test_install_versions_silent_no_warning(self, arm64_platform, mock_dependencies): + """Does not show warning when silent=True even on ARM64.""" + # Create service with ARM64 platform + service = SolcService(platform=arm64_platform) + service.filesystem = mock_dependencies["filesystem"] + service.version_manager = mock_dependencies["version_manager"] + service.artifact_manager = mock_dependencies["artifact_manager"] + service.platform_service = mock_dependencies["platform_service"] + + versions = [SolcVersion("0.8.19")] + mock_dependencies["version_manager"].resolve_version_strings.return_value = versions + mock_dependencies["version_manager"].get_available_versions.return_value = versions + mock_dependencies["artifact_manager"].install_versions.return_value = True + + service.install_versions(["0.8.19"], silent=True) + + mock_dependencies["platform_service"].warn_about_arm64_compatibility.assert_not_called() + + def test_install_versions_empty_list(self, solc_service, mock_dependencies): + """Returns True immediately for empty version list.""" + result = solc_service.install_versions([]) + + assert result is True + mock_dependencies["version_manager"].resolve_version_strings.assert_not_called() + mock_dependencies["artifact_manager"].install_versions.assert_not_called() + + def test_install_versions_success(self, solc_service, mock_dependencies): + """Delegates to ArtifactManager for successful installation.""" + versions = [SolcVersion("0.8.19"), SolcVersion("0.8.20")] + mock_dependencies["version_manager"].resolve_version_strings.return_value = versions + mock_dependencies["version_manager"].get_available_versions.return_value = versions + mock_dependencies["artifact_manager"].install_versions.return_value = True + + result = solc_service.install_versions(["0.8.19", "0.8.20"]) + + assert result is True + mock_dependencies["version_manager"].resolve_version_strings.assert_called_once_with( + ["0.8.19", "0.8.20"] + ) + mock_dependencies["artifact_manager"].install_versions.assert_called_once_with( + versions, False + ) + + def test_install_versions_filters_unavailable(self, solc_service, mock_dependencies, capsys): + """Warns about unavailable versions and returns False.""" + requested = [SolcVersion("0.8.19"), SolcVersion("0.8.99")] + available = [SolcVersion("0.8.19"), SolcVersion("0.8.20")] + mock_dependencies["version_manager"].resolve_version_strings.return_value = requested + mock_dependencies["version_manager"].get_available_versions.return_value = available + + result = solc_service.install_versions(["0.8.19", "0.8.99"]) + + assert result is False + captured = capsys.readouterr() + assert "0.8.99 solc versions are not available" in captured.out + mock_dependencies["artifact_manager"].install_versions.assert_not_called() + + @pytest.mark.parametrize("silent", [False, True]) + def test_install_versions_error_handling(self, solc_service, mock_dependencies, capsys, silent): + """Returns False on exception, prints error only when not silent.""" + mock_dependencies[ + "version_manager" + ].resolve_version_strings.side_effect = VersionNotFoundError("0.8.99", []) + + result = solc_service.install_versions(["0.8.99"], silent=silent) + + assert result is False + captured = capsys.readouterr() + if silent: + assert captured.out == "" + else: + assert "Error:" in captured.out + assert "0.8.99" in captured.out + mock_dependencies["artifact_manager"].install_versions.assert_not_called() + + +class TestSolcServiceExecute: + """Tests for executing solc binaries.""" + + @patch("subprocess.run") + def test_execute_solc_auto_install_latest( + self, mock_subprocess, solc_service, mock_dependencies + ): + """Installs latest version if none are installed.""" + version = SolcVersion("0.8.21") + binary_path = Path("/home/user/.solc-select/artifacts/solc-0.8.21") + + # No versions installed initially + mock_dependencies["filesystem"].get_installed_versions.return_value = [] + # After auto-install, version is set + mock_dependencies["filesystem"].get_current_version.return_value = version + mock_dependencies["filesystem"].get_version_source.return_value = "auto" + mock_dependencies["filesystem"].is_installed.return_value = True + mock_dependencies["filesystem"].get_binary_path.return_value = binary_path + + # Mock the validation and installation flow + mock_dependencies["version_manager"].validate_version.return_value = version + mock_dependencies["version_manager"].resolve_version_strings.return_value = [version] + mock_dependencies["version_manager"].get_available_versions.return_value = [version] + mock_dependencies["artifact_manager"].install_versions.return_value = True + + # Mock artifact creation + mock_artifact = Mock() + mock_artifact.emulation = None + mock_dependencies[ + "artifact_manager" + ].create_local_artifact_metadata.return_value = mock_artifact + mock_dependencies["platform_service"].get_emulation_prefix.return_value = [] + + solc_service.execute_solc(["--version"]) + + # Should have called switch_global_version with "latest" and always_install=True + mock_dependencies["version_manager"].validate_version.assert_called_with("latest") + mock_subprocess.assert_called_once_with([str(binary_path), "--version"], check=True) + + @patch("subprocess.run") + def test_execute_solc_success(self, mock_subprocess, solc_service, mock_dependencies): + """Runs subprocess with correct args for normal execution.""" + version = SolcVersion("0.8.19") + binary_path = Path("/home/user/.solc-select/artifacts/solc-0.8.19") + + mock_dependencies["filesystem"].get_installed_versions.return_value = [version] + mock_dependencies["filesystem"].get_current_version.return_value = version + mock_dependencies["filesystem"].get_version_source.return_value = "global" + mock_dependencies["filesystem"].is_installed.return_value = True + mock_dependencies["filesystem"].get_binary_path.return_value = binary_path + + mock_artifact = Mock() + mock_artifact.emulation = None + mock_dependencies[ + "artifact_manager" + ].create_local_artifact_metadata.return_value = mock_artifact + mock_dependencies["platform_service"].get_emulation_prefix.return_value = [] + + solc_service.execute_solc(["--version"]) + + mock_subprocess.assert_called_once_with([str(binary_path), "--version"], check=True) + + @patch("subprocess.run") + def test_execute_solc_with_emulation(self, mock_subprocess, solc_service, mock_dependencies): + """Prepends emulation prefix when needed.""" + version = SolcVersion("0.8.19") + binary_path = Path("/home/user/.solc-select/artifacts/solc-0.8.19") + + mock_dependencies["filesystem"].get_installed_versions.return_value = [version] + mock_dependencies["filesystem"].get_current_version.return_value = version + mock_dependencies["filesystem"].get_version_source.return_value = "global" + mock_dependencies["filesystem"].is_installed.return_value = True + mock_dependencies["filesystem"].get_binary_path.return_value = binary_path + + mock_artifact = Mock() + mock_artifact.emulation = Mock() + mock_dependencies[ + "artifact_manager" + ].create_local_artifact_metadata.return_value = mock_artifact + mock_dependencies["platform_service"].get_emulation_prefix.return_value = ["qemu-x86_64"] + + solc_service.execute_solc(["--version"]) + + mock_subprocess.assert_called_once_with( + ["qemu-x86_64", str(binary_path), "--version"], check=True + ) + + @patch("subprocess.run") + @patch("sys.exit") + def test_execute_solc_no_version_set( + self, mock_exit, mock_subprocess, solc_service, mock_dependencies, capsys + ): + """Handles NoVersionSetError gracefully.""" + # Make sys.exit raise SystemExit to stop execution + mock_exit.side_effect = SystemExit(1) + + mock_dependencies["filesystem"].get_installed_versions.return_value = [ + SolcVersion("0.8.19") + ] + mock_dependencies["filesystem"].get_current_version.return_value = None + + with pytest.raises(SystemExit): + solc_service.execute_solc(["--version"]) + + captured = capsys.readouterr() + assert "Error:" in captured.err + assert "No solc version set" in captured.err + mock_exit.assert_called_once_with(1) + mock_subprocess.assert_not_called() + + @patch("subprocess.run") + @patch("sys.exit") + def test_execute_solc_emulation_unavailable( + self, mock_exit, mock_subprocess, solc_service, mock_dependencies, capsys + ): + """Handles RuntimeError from missing emulation.""" + # Make sys.exit raise SystemExit to stop execution + mock_exit.side_effect = SystemExit(1) + + version = SolcVersion("0.8.19") + binary_path = Path("/home/user/.solc-select/artifacts/solc-0.8.19") + + mock_dependencies["filesystem"].get_installed_versions.return_value = [version] + mock_dependencies["filesystem"].get_current_version.return_value = version + mock_dependencies["filesystem"].get_version_source.return_value = "global" + mock_dependencies["filesystem"].is_installed.return_value = True + mock_dependencies["filesystem"].get_binary_path.return_value = binary_path + + mock_artifact = Mock() + mock_artifact.emulation = Mock() + mock_dependencies[ + "artifact_manager" + ].create_local_artifact_metadata.return_value = mock_artifact + mock_dependencies["platform_service"].get_emulation_prefix.side_effect = RuntimeError( + "QEMU is required but not available" + ) + + with pytest.raises(SystemExit): + solc_service.execute_solc(["--version"]) + + captured = capsys.readouterr() + assert "Error:" in captured.err + assert "QEMU is required but not available" in captured.err + mock_exit.assert_called_once_with(1) + mock_subprocess.assert_not_called() + + @patch("subprocess.run") + @patch("sys.exit") + def test_execute_solc_subprocess_error( + self, mock_exit, mock_subprocess, solc_service, mock_dependencies + ): + """Propagates CalledProcessError exit code.""" + version = SolcVersion("0.8.19") + binary_path = Path("/home/user/.solc-select/artifacts/solc-0.8.19") + + mock_dependencies["filesystem"].get_installed_versions.return_value = [version] + mock_dependencies["filesystem"].get_current_version.return_value = version + mock_dependencies["filesystem"].get_version_source.return_value = "global" + mock_dependencies["filesystem"].is_installed.return_value = True + mock_dependencies["filesystem"].get_binary_path.return_value = binary_path + + mock_artifact = Mock() + mock_artifact.emulation = None + mock_dependencies[ + "artifact_manager" + ].create_local_artifact_metadata.return_value = mock_artifact + mock_dependencies["platform_service"].get_emulation_prefix.return_value = [] + + mock_subprocess.side_effect = subprocess.CalledProcessError(2, "solc") + + solc_service.execute_solc(["invalid-file.sol"]) + + mock_exit.assert_called_once_with(2) + + @patch("subprocess.run") + @patch("sys.exit") + def test_execute_solc_binary_not_found( + self, mock_exit, mock_subprocess, solc_service, mock_dependencies, capsys + ): + """Handles FileNotFoundError when binary doesn't exist.""" + version = SolcVersion("0.8.19") + binary_path = Path("/home/user/.solc-select/artifacts/solc-0.8.19") + + mock_dependencies["filesystem"].get_installed_versions.return_value = [version] + mock_dependencies["filesystem"].get_current_version.return_value = version + mock_dependencies["filesystem"].get_version_source.return_value = "global" + mock_dependencies["filesystem"].is_installed.return_value = True + mock_dependencies["filesystem"].get_binary_path.return_value = binary_path + + mock_artifact = Mock() + mock_artifact.emulation = None + mock_dependencies[ + "artifact_manager" + ].create_local_artifact_metadata.return_value = mock_artifact + mock_dependencies["platform_service"].get_emulation_prefix.return_value = [] + + mock_subprocess.side_effect = FileNotFoundError() + + solc_service.execute_solc(["--version"]) + + captured = capsys.readouterr() + assert "Error:" in captured.err + assert "Could not execute solc binary at" in captured.err + assert str(binary_path) in captured.err + mock_exit.assert_called_once_with(1) diff --git a/tests/unit/services/test_version_manager.py b/tests/unit/services/test_version_manager.py new file mode 100644 index 0000000..043348d --- /dev/null +++ b/tests/unit/services/test_version_manager.py @@ -0,0 +1,451 @@ +"""Unit tests for VersionManager service.""" + +from typing import Any +from unittest.mock import Mock + +import pytest + +from solc_select.exceptions import ( + PlatformNotSupportedError, + VersionNotFoundError, + VersionResolutionError, +) +from solc_select.models.platforms import Platform +from solc_select.models.versions import SolcVersion +from solc_select.services.repository_matcher import RepositoryMatcher +from solc_select.services.version_manager import VersionManager + + +@pytest.fixture +def mock_repository_matcher() -> Mock: + """Mock RepositoryMatcher for testing.""" + return Mock(spec=RepositoryMatcher) + + +@pytest.fixture +def platform() -> Platform: + """Platform instance for testing.""" + return Platform("linux", "amd64") + + +@pytest.fixture +def version_manager(mock_repository_matcher: Mock, platform: Platform) -> VersionManager: + """VersionManager instance with mocked dependencies.""" + return VersionManager(mock_repository_matcher, platform) + + +@pytest.fixture +def sample_available_versions() -> dict[SolcVersion, tuple[Any, Any]]: + """Sample version dictionary for testing.""" + return { + SolcVersion("0.8.17"): (Mock(), Mock()), + SolcVersion("0.8.18"): (Mock(), Mock()), + SolcVersion("0.8.19"): (Mock(), Mock()), + SolcVersion("0.8.20"): (Mock(), Mock()), + SolcVersion("0.8.21"): (Mock(), Mock()), + } + + +class TestVersionManagerValidation: + """Tests for version validation functionality.""" + + def test_validate_version_valid_format( + self, + version_manager: VersionManager, + mock_repository_matcher: Mock, + sample_available_versions: dict[SolcVersion, tuple[Any, Any]], + ) -> None: + """Test that valid version strings are parsed successfully.""" + # Arrange + mock_repository_matcher.find_repository_for_version.return_value = (Mock(), Mock()) + mock_repository_matcher.get_all_available_versions.return_value = sample_available_versions + + # Act + result = version_manager.validate_version("0.8.19") + + # Assert + assert isinstance(result, SolcVersion) + assert str(result) == "0.8.19" + mock_repository_matcher.find_repository_for_version.assert_called_once() + + def test_validate_version_latest_keyword( + self, + version_manager: VersionManager, + mock_repository_matcher: Mock, + sample_available_versions: dict[SolcVersion, tuple[Any, Any]], + ) -> None: + """Test that 'latest' keyword resolves to highest available version.""" + # Arrange + mock_repository_matcher.get_all_available_versions.return_value = sample_available_versions + + # Act + result = version_manager.validate_version("latest") + + # Assert + assert result == SolcVersion("0.8.21") + + def test_validate_version_invalid_format( + self, + version_manager: VersionManager, + mock_repository_matcher: Mock, + sample_available_versions: dict[SolcVersion, tuple[Any, Any]], + ) -> None: + """Test that invalid version format raises VersionNotFoundError with helpful message.""" + # Arrange + mock_repository_matcher.get_all_available_versions.return_value = sample_available_versions + + # Act & Assert + with pytest.raises(VersionNotFoundError) as exc_info: + version_manager.validate_version("invalid.version") + + # Verify error details + error = exc_info.value + assert error.version == "invalid.version" + assert len(error.available_versions) == 5 + assert error.suggestion == "Check the version format (e.g., '0.8.19')" + assert "Check the version format" in str(error) + + def test_validate_version_not_found( + self, + version_manager: VersionManager, + mock_repository_matcher: Mock, + sample_available_versions: dict[SolcVersion, tuple[Any, Any]], + ) -> None: + """Test that non-existent version raises VersionNotFoundError.""" + # Arrange + mock_repository_matcher.find_repository_for_version.side_effect = VersionNotFoundError( + "99.99.99" + ) + mock_repository_matcher.get_all_available_versions.return_value = sample_available_versions + + # Act & Assert + with pytest.raises(VersionNotFoundError) as exc_info: + version_manager.validate_version("99.99.99") + + # Verify error includes suggestion for latest version + error = exc_info.value + assert error.version == "99.99.99" + assert error.suggestion == "'0.8.21' is the latest available version" + + def test_validate_version_platform_not_supported( + self, + version_manager: VersionManager, + mock_repository_matcher: Mock, + sample_available_versions: dict[SolcVersion, tuple[Any, Any]], + ) -> None: + """Test that version below platform minimum raises PlatformNotSupportedError.""" + # Arrange + mock_repository_matcher.find_repository_for_version.side_effect = VersionNotFoundError( + "0.4.0" + ) + mock_repository_matcher.get_all_available_versions.return_value = sample_available_versions + + # Act & Assert + with pytest.raises(PlatformNotSupportedError) as exc_info: + version_manager.validate_version("0.4.0") + + # Verify error details + error = exc_info.value + assert error.version == "0.4.0" + assert error.platform == "linux-amd64" + assert error.min_version == "0.8.17" + assert "Minimum supported version" in str(error) + + def test_validate_version_helpful_error_messages( + self, + version_manager: VersionManager, + mock_repository_matcher: Mock, + sample_available_versions: dict[SolcVersion, tuple[Any, Any]], + ) -> None: + """Test that error messages include helpful suggestions and available versions.""" + # Arrange + mock_repository_matcher.find_repository_for_version.side_effect = VersionNotFoundError( + "0.8.22" + ) + mock_repository_matcher.get_all_available_versions.return_value = sample_available_versions + + # Act & Assert - version above latest + with pytest.raises(VersionNotFoundError) as exc_info: + version_manager.validate_version("0.8.22") + + error = exc_info.value + assert error.version == "0.8.22" + assert error.suggestion == "'0.8.21' is the latest available version" + + def test_validate_version_no_versions_available( + self, version_manager: VersionManager, mock_repository_matcher: Mock + ) -> None: + """Test handling when no versions are available at all.""" + # Arrange + mock_repository_matcher.find_repository_for_version.side_effect = VersionNotFoundError( + "0.8.19" + ) + mock_repository_matcher.get_all_available_versions.return_value = {} + + # Act & Assert + with pytest.raises(VersionNotFoundError) as exc_info: + version_manager.validate_version("0.8.19") + + error = exc_info.value + assert error.version == "0.8.19" + assert error.available_versions == [] + + def test_validate_version_latest_no_versions_available( + self, version_manager: VersionManager, mock_repository_matcher: Mock + ) -> None: + """Test that 'latest' with no versions raises VersionResolutionError.""" + # Arrange + mock_repository_matcher.get_all_available_versions.return_value = {} + + # Act & Assert + with pytest.raises(VersionResolutionError) as exc_info: + version_manager.validate_version("latest") + + error = exc_info.value + assert error.requested == "latest" + assert "No versions available" in error.reason + + +class TestVersionManagerResolution: + """Tests for version resolution functionality.""" + + def test_resolve_all_keyword( + self, + version_manager: VersionManager, + mock_repository_matcher: Mock, + sample_available_versions: dict[SolcVersion, tuple[Any, Any]], + ) -> None: + """Test that 'all' keyword returns all available versions.""" + # Arrange + mock_repository_matcher.get_all_available_versions.return_value = sample_available_versions + + # Act + result = version_manager.resolve_version_strings(["all"]) + + # Assert + assert len(result) == 5 + assert SolcVersion("0.8.17") in result + assert SolcVersion("0.8.21") in result + # Should be sorted + assert result == sorted(result) + + def test_resolve_latest_keyword( + self, + version_manager: VersionManager, + mock_repository_matcher: Mock, + sample_available_versions: dict[SolcVersion, tuple[Any, Any]], + ) -> None: + """Test that 'latest' is resolved in list.""" + # Arrange + mock_repository_matcher.get_all_available_versions.return_value = sample_available_versions + mock_repository_matcher.find_repository_for_version.return_value = (Mock(), Mock()) + + # Act + result = version_manager.resolve_version_strings(["latest"]) + + # Assert + assert len(result) == 1 + assert result[0] == SolcVersion("0.8.21") + + def test_resolve_mixed_versions( + self, + version_manager: VersionManager, + mock_repository_matcher: Mock, + sample_available_versions: dict[SolcVersion, tuple[Any, Any]], + ) -> None: + """Test resolving mixed list with 'latest', explicit versions.""" + # Arrange + mock_repository_matcher.get_all_available_versions.return_value = sample_available_versions + mock_repository_matcher.find_repository_for_version.return_value = (Mock(), Mock()) + + # Act + result = version_manager.resolve_version_strings(["0.8.19", "latest", "0.8.20"]) + + # Assert + assert len(result) == 3 + assert result[0] == SolcVersion("0.8.19") + assert result[1] == SolcVersion("0.8.21") # latest + assert result[2] == SolcVersion("0.8.20") + + def test_resolve_empty_list( + self, version_manager: VersionManager, mock_repository_matcher: Mock + ) -> None: + """Test that empty list returns empty list.""" + # Act + result = version_manager.resolve_version_strings([]) + + # Assert + assert result == [] + # Should not make any repository calls + mock_repository_matcher.get_all_available_versions.assert_not_called() + + def test_resolve_duplicate_versions( + self, + version_manager: VersionManager, + mock_repository_matcher: Mock, + sample_available_versions: dict[SolcVersion, tuple[Any, Any]], + ) -> None: + """Test resolving list with duplicate versions.""" + # Arrange + mock_repository_matcher.get_all_available_versions.return_value = sample_available_versions + mock_repository_matcher.find_repository_for_version.return_value = (Mock(), Mock()) + + # Act + result = version_manager.resolve_version_strings(["0.8.19", "0.8.19", "0.8.20"]) + + # Assert + assert len(result) == 3 + # Duplicates are preserved (caller's responsibility to handle) + assert result[0] == SolcVersion("0.8.19") + assert result[1] == SolcVersion("0.8.19") + assert result[2] == SolcVersion("0.8.20") + + +class TestVersionManagerAvailability: + """Tests for version availability functionality.""" + + def test_get_latest_version_success( + self, + version_manager: VersionManager, + mock_repository_matcher: Mock, + sample_available_versions: dict[SolcVersion, tuple[Any, Any]], + ) -> None: + """Test that get_latest_version returns the highest version.""" + # Arrange + mock_repository_matcher.get_all_available_versions.return_value = sample_available_versions + + # Act + result = version_manager.get_latest_version() + + # Assert + assert result == SolcVersion("0.8.21") + + def test_get_latest_version_no_versions( + self, version_manager: VersionManager, mock_repository_matcher: Mock + ) -> None: + """Test that get_latest_version raises ValueError when no versions available.""" + # Arrange + mock_repository_matcher.get_all_available_versions.return_value = {} + + # Act & Assert + with pytest.raises(ValueError) as exc_info: + version_manager.get_latest_version() + + assert "No versions available" in str(exc_info.value) + + def test_get_available_versions_sorted( + self, + version_manager: VersionManager, + mock_repository_matcher: Mock, + sample_available_versions: dict[SolcVersion, tuple[Any, Any]], + ) -> None: + """Test that get_available_versions returns sorted list.""" + # Arrange + mock_repository_matcher.get_all_available_versions.return_value = sample_available_versions + + # Act + result = version_manager.get_available_versions() + + # Assert + assert len(result) == 5 + assert result == sorted(result) + assert result[0] == SolcVersion("0.8.17") + assert result[-1] == SolcVersion("0.8.21") + + def test_get_available_versions_empty( + self, version_manager: VersionManager, mock_repository_matcher: Mock + ) -> None: + """Test that get_available_versions handles empty repository.""" + # Arrange + mock_repository_matcher.get_all_available_versions.return_value = {} + + # Act + result = version_manager.get_available_versions() + + # Assert + assert result == [] + + def test_get_installable_versions( + self, + version_manager: VersionManager, + mock_repository_matcher: Mock, + sample_available_versions: dict[SolcVersion, tuple[Any, Any]], + ) -> None: + """Test that get_installable_versions filters out installed versions.""" + # Arrange + mock_repository_matcher.get_all_available_versions.return_value = sample_available_versions + installed: list[SolcVersion] = [SolcVersion("0.8.19"), SolcVersion("0.8.20")] + + # Act + result = version_manager.get_installable_versions(installed) + + # Assert + assert len(result) == 3 + assert SolcVersion("0.8.19") not in result + assert SolcVersion("0.8.20") not in result + assert SolcVersion("0.8.17") in result + assert SolcVersion("0.8.18") in result + assert SolcVersion("0.8.21") in result + + def test_get_installable_versions_all_installed( + self, + version_manager: VersionManager, + mock_repository_matcher: Mock, + sample_available_versions: dict[SolcVersion, tuple[Any, Any]], + ) -> None: + """Test that get_installable_versions returns empty when all are installed.""" + # Arrange + mock_repository_matcher.get_all_available_versions.return_value = sample_available_versions + installed = list(sample_available_versions.keys()) + + # Act + result = version_manager.get_installable_versions(installed) + + # Assert + assert result == [] + + def test_get_installable_versions_none_installed( + self, + version_manager: VersionManager, + mock_repository_matcher: Mock, + sample_available_versions: dict[SolcVersion, tuple[Any, Any]], + ) -> None: + """Test that get_installable_versions returns all when none installed.""" + # Arrange + mock_repository_matcher.get_all_available_versions.return_value = sample_available_versions + installed: list[SolcVersion] = [] + + # Act + result = version_manager.get_installable_versions(installed) + + # Assert + assert len(result) == 5 + assert result == sorted(sample_available_versions.keys()) + + +class TestVersionManagerIntegration: + """Integration tests combining multiple VersionManager operations.""" + + @pytest.mark.parametrize( + "version_string,expected", + [ + ("0.8.19", SolcVersion("0.8.19")), + ("0.8.20", SolcVersion("0.8.20")), + ("0.8.21", SolcVersion("0.8.21")), + ], + ) + def test_validate_version_parametrized( + self, + version_manager: VersionManager, + mock_repository_matcher: Mock, + sample_available_versions: dict[SolcVersion, tuple[Any, Any]], + version_string: str, + expected: SolcVersion, + ) -> None: + """Test validation of multiple versions using parametrization.""" + mock_repository_matcher.get_all_available_versions.return_value = sample_available_versions + mock_repository_matcher.find_repository_for_version.return_value = (Mock(), Mock()) + + result = version_manager.validate_version(version_string) + + assert result == expected diff --git a/tests/unit/test_repositories.py b/tests/unit/test_repositories.py new file mode 100644 index 0000000..9d404fb --- /dev/null +++ b/tests/unit/test_repositories.py @@ -0,0 +1,447 @@ +"""Unit tests for SolcRepository implementations.""" + +from unittest.mock import Mock + +import pytest +import requests + +from solc_select.constants import ( + ALLOY_SOLC_ARTIFACTS, + CRYTIC_SOLC_ARTIFACTS, +) +from solc_select.models.platforms import Platform +from solc_select.models.versions import SolcVersion +from solc_select.repositories import ( + AlloyRepository, + CryticRepository, + SolcRepository, + SoliditylangRepository, +) + + +class TestSolcRepositoryVersions: + """Tests for available_versions property and version parsing.""" + + def test_available_versions_parses_json(self, mock_session: Mock) -> None: + """Test that available_versions extracts versions from list.json.""" + # Arrange + mock_response = Mock() + mock_response.json.return_value = { + "releases": { + "0.8.19": "solc-linux-amd64-v0.8.19+commit.abc123", + "0.8.20": "solc-linux-amd64-v0.8.20+commit.def456", + }, + "builds": [], + } + mock_session.get.return_value = mock_response + + repository = SolcRepository( + base_url="https://example.com/", + list_url="https://example.com/list.json", + session=mock_session, + ) + + # Act + versions = repository.available_versions + + # Assert + assert "0.8.19" in versions + assert "0.8.20" in versions + assert versions["0.8.19"] == "solc-linux-amd64-v0.8.19+commit.abc123" + assert versions["0.8.20"] == "solc-linux-amd64-v0.8.20+commit.def456" + mock_session.get.assert_called_once_with("https://example.com/list.json") + + def test_available_versions_filters_prerelease(self, mock_session: Mock) -> None: + """Test that available_versions includes prerelease versions in releases.""" + # Arrange - Prerelease versions ARE included in the releases dict + mock_response = Mock() + mock_response.json.return_value = { + "releases": { + "0.8.19": "solc-linux-amd64-v0.8.19+commit.abc123", + "0.8.20-nightly.2023.1.1": "solc-linux-amd64-v0.8.20-nightly+commit.xyz", + }, + "builds": [], + } + mock_session.get.return_value = mock_response + + repository = SolcRepository( + base_url="https://example.com/", + list_url="https://example.com/list.json", + session=mock_session, + ) + + # Act + versions = repository.available_versions + + # Assert - Repository returns releases dict as-is + assert "0.8.19" in versions + assert "0.8.20-nightly.2023.1.1" in versions + + def test_available_versions_caches(self, mock_session: Mock) -> None: + """Test that lru_cache works and same object is returned.""" + # Arrange + mock_response = Mock() + mock_response.json.return_value = { + "releases": {"0.8.19": "solc-linux-amd64-v0.8.19+commit.abc123"}, + "builds": [], + } + mock_session.get.return_value = mock_response + + repository = SolcRepository( + base_url="https://example.com/", + list_url="https://example.com/list.json", + session=mock_session, + ) + + # Act + versions1 = repository.available_versions + versions2 = repository.available_versions + + # Assert - Same object reference (cached) + assert versions1 is versions2 + # Session.get should only be called once due to caching + mock_session.get.assert_called_once() + + def test_available_versions_network_error(self, mock_session: Mock) -> None: + """Test that network errors raise HTTPError.""" + # Arrange + mock_response = Mock() + mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found") + mock_session.get.return_value = mock_response + + repository = SolcRepository( + base_url="https://example.com/", + list_url="https://example.com/list.json", + session=mock_session, + ) + + # Act & Assert + with pytest.raises(requests.HTTPError): + _ = repository.available_versions + + +class TestSolcRepositoryChecksums: + """Tests for get_checksums method.""" + + def test_get_checksums_sha256_only(self, mock_session: Mock) -> None: + """Test extracting SHA256 checksum only.""" + # Arrange + mock_response = Mock() + mock_response.json.return_value = { + "releases": {"0.8.19": "solc-linux-amd64-v0.8.19+commit.abc123"}, + "builds": [ + { + "version": "0.8.19", + "sha256": "0xabc123def456", + "path": "solc-linux-amd64-v0.8.19+commit.abc123", + } + ], + } + mock_session.get.return_value = mock_response + + repository = SolcRepository( + base_url="https://example.com/", + list_url="https://example.com/list.json", + session=mock_session, + ) + + # Act + sha256, keccak256 = repository.get_checksums(SolcVersion("0.8.19")) + + # Assert + assert sha256 == "abc123def456" + assert keccak256 is None + + def test_get_checksums_sha256_and_keccak256(self, mock_session: Mock) -> None: + """Test extracting both SHA256 and Keccak256 checksums.""" + # Arrange + mock_response = Mock() + mock_response.json.return_value = { + "releases": {"0.8.19": "solc-linux-amd64-v0.8.19+commit.abc123"}, + "builds": [ + { + "version": "0.8.19", + "sha256": "0xabc123def456", + "keccak256": "0x789ghi012jkl", + "path": "solc-linux-amd64-v0.8.19+commit.abc123", + } + ], + } + mock_session.get.return_value = mock_response + + repository = SolcRepository( + base_url="https://example.com/", + list_url="https://example.com/list.json", + session=mock_session, + ) + + # Act + sha256, keccak256 = repository.get_checksums(SolcVersion("0.8.19")) + + # Assert + assert sha256 == "abc123def456" + assert keccak256 == "789ghi012jkl" + + def test_get_checksums_removes_0x_prefix(self, mock_session: Mock) -> None: + """Test that 0x prefix is stripped from checksums.""" + # Arrange + mock_response = Mock() + mock_response.json.return_value = { + "releases": {"0.8.19": "solc-linux-amd64-v0.8.19+commit.abc123"}, + "builds": [ + { + "version": "0.8.19", + "sha256": "0xfedcba987654", + "keccak256": "0x123456789abc", + "path": "solc-linux-amd64-v0.8.19+commit.abc123", + } + ], + } + mock_session.get.return_value = mock_response + + repository = SolcRepository( + base_url="https://example.com/", + list_url="https://example.com/list.json", + session=mock_session, + ) + + # Act + sha256, keccak256 = repository.get_checksums(SolcVersion("0.8.19")) + + # Assert + assert not sha256.startswith("0x") + assert keccak256 is not None + assert not keccak256.startswith("0x") + assert sha256 == "fedcba987654" + assert keccak256 == "123456789abc" + + def test_get_checksums_version_not_found(self, mock_session: Mock) -> None: + """Test that ValueError is raised when version not found.""" + # Arrange + mock_response = Mock() + mock_response.json.return_value = { + "releases": {"0.8.19": "solc-linux-amd64-v0.8.19+commit.abc123"}, + "builds": [ + { + "version": "0.8.19", + "sha256": "0xabc123", + "path": "solc-linux-amd64-v0.8.19+commit.abc123", + } + ], + } + mock_session.get.return_value = mock_response + + repository = SolcRepository( + base_url="https://example.com/", + list_url="https://example.com/list.json", + session=mock_session, + ) + + # Act & Assert + with pytest.raises(ValueError, match=r"Unable to retrieve checksum for 0\.8\.20"): + repository.get_checksums(SolcVersion("0.8.20")) + + def test_get_checksums_missing_checksum(self, mock_session: Mock) -> None: + """Test that ValueError is raised when checksum is missing.""" + # Arrange + mock_response = Mock() + mock_response.json.return_value = { + "releases": {"0.8.19": "solc-linux-amd64-v0.8.19+commit.abc123"}, + "builds": [ + { + "version": "0.8.19", + "sha256": None, # Missing checksum + "path": "solc-linux-amd64-v0.8.19+commit.abc123", + } + ], + } + mock_session.get.return_value = mock_response + + repository = SolcRepository( + base_url="https://example.com/", + list_url="https://example.com/list.json", + session=mock_session, + ) + + # Act & Assert + with pytest.raises(ValueError, match=r"Unable to retrieve checksum for 0\.8\.19"): + repository.get_checksums(SolcVersion("0.8.19")) + + def test_get_checksums_filters_prerelease(self, mock_session: Mock) -> None: + """Test that prerelease builds are filtered out when getting checksums.""" + # Arrange + mock_response = Mock() + mock_response.json.return_value = { + "releases": {"0.8.19": "solc-linux-amd64-v0.8.19+commit.abc123"}, + "builds": [ + { + "version": "0.8.19", + "sha256": "0x111222333", + "path": "solc-linux-amd64-v0.8.19-nightly+commit.xyz", + "prerelease": "nightly", + }, + { + "version": "0.8.19", + "sha256": "0xabc123def456", + "path": "solc-linux-amd64-v0.8.19+commit.abc123", + }, + ], + } + mock_session.get.return_value = mock_response + + repository = SolcRepository( + base_url="https://example.com/", + list_url="https://example.com/list.json", + session=mock_session, + ) + + # Act + sha256, _ = repository.get_checksums(SolcVersion("0.8.19")) + + # Assert - Should get the non-prerelease checksum + assert sha256 == "abc123def456" + + +class TestSolcRepositoryLatest: + """Tests for latest_version property.""" + + def test_latest_version_from_field(self, mock_session: Mock) -> None: + """Test that latest_version uses latestRelease field when available.""" + # Arrange + mock_response = Mock() + mock_response.json.return_value = { + "latestRelease": "0.8.21", + "releases": { + "0.8.19": "solc-linux-amd64-v0.8.19+commit.abc123", + "0.8.20": "solc-linux-amd64-v0.8.20+commit.def456", + "0.8.21": "solc-linux-amd64-v0.8.21+commit.ghi789", + }, + "builds": [], + } + mock_session.get.return_value = mock_response + + repository = SolcRepository( + base_url="https://example.com/", + list_url="https://example.com/list.json", + session=mock_session, + has_latest_release=True, + ) + + # Act + latest = repository.latest_version + + # Assert + assert latest == SolcVersion("0.8.21") + + def test_latest_version_from_max(self, mock_session: Mock) -> None: + """Test that latest_version computes max when no latestRelease field.""" + # Arrange + mock_response = Mock() + mock_response.json.return_value = { + "releases": { + "0.8.19": "solc-linux-amd64-v0.8.19+commit.abc123", + "0.8.20": "solc-linux-amd64-v0.8.20+commit.def456", + "0.8.18": "solc-linux-amd64-v0.8.18+commit.ghi789", + }, + "builds": [], + } + mock_session.get.return_value = mock_response + + repository = SolcRepository( + base_url="https://example.com/", + list_url="https://example.com/list.json", + session=mock_session, + has_latest_release=False, + ) + + # Act + latest = repository.latest_version + + # Assert + assert latest == SolcVersion("0.8.20") + + def test_latest_version_no_versions(self, mock_session: Mock) -> None: + """Test that ValueError is raised when no versions available.""" + # Arrange + mock_response = Mock() + mock_response.json.return_value = { + "releases": {}, + "builds": [], + } + mock_session.get.return_value = mock_response + + repository = SolcRepository( + base_url="https://example.com/", + list_url="https://example.com/list.json", + session=mock_session, + has_latest_release=False, + ) + + # Act & Assert + with pytest.raises(ValueError, match="No versions available"): + _ = repository.latest_version + + +class TestSolcRepositoryFactory: + """Tests for repository factory functions.""" + + @pytest.mark.parametrize( + "platform_args,expected_url_part", + [ + (("linux", "amd64"), "linux-amd64"), + (("darwin", "arm64"), "macosx-amd64"), # ARM64 uses x86 binaries via emulation + ], + ) + def test_soliditylang_repository_creation( + self, mock_session: Mock, platform_args: tuple[str, str], expected_url_part: str + ) -> None: + """SoliditylangRepository creates correct URLs for different platforms.""" + repository = SoliditylangRepository(Platform(*platform_args), mock_session) + + assert isinstance(repository, SolcRepository) + assert expected_url_part in repository.base_url + assert repository.session is mock_session + assert repository._has_latest_release is True + + def test_crytic_repository_creation(self, mock_session: Mock) -> None: + """CryticRepository creates correct instance.""" + repository = CryticRepository(mock_session) + + assert isinstance(repository, SolcRepository) + assert repository.base_url == CRYTIC_SOLC_ARTIFACTS + assert repository._has_latest_release is False + + def test_alloy_repository_creation(self, mock_session: Mock) -> None: + """AlloyRepository creates correct instance.""" + repository = AlloyRepository(mock_session) + + assert isinstance(repository, SolcRepository) + assert repository.base_url == ALLOY_SOLC_ARTIFACTS + assert repository._has_latest_release is False + + +class TestSolcRepositoryURL: + """Tests for get_download_url method.""" + + @pytest.mark.parametrize( + "base_url,filename", + [ + ("https://example.com/artifacts/", "solc-linux-amd64-v0.8.19+commit.abc123"), + ( + "https://binaries.soliditylang.org/linux-amd64/", + "solc-linux-amd64-v0.8.19+commit.7dd6d40", + ), + ], + ) + def test_get_download_url(self, mock_session: Mock, base_url: str, filename: str) -> None: + """URL construction concatenates base_url with artifact filename.""" + repository = SolcRepository( + base_url=base_url, + list_url=f"{base_url}list.json", + session=mock_session, + ) + + url = repository.get_download_url(filename) + + assert url == f"{base_url}{filename}" + assert url.startswith(repository.base_url)