""" Tests for IPFS access consistency. All IPFS access should use IPFS_API (multiaddr format) for consistency with art-celery's ipfs_client.py. This ensures Docker deployments work correctly since IPFS_API is set to /dns/ipfs/tcp/5001. """ import os import re from pathlib import Path from typing import Optional from unittest.mock import patch, MagicMock import pytest def multiaddr_to_url(multiaddr: str) -> str: """ Convert IPFS multiaddr to HTTP URL. This is the canonical conversion used by ipfs_client.py. """ # Handle /dns/hostname/tcp/port format dns_match = re.match(r"/dns[46]?/([^/]+)/tcp/(\d+)", multiaddr) if dns_match: return f"http://{dns_match.group(1)}:{dns_match.group(2)}" # Handle /ip4/address/tcp/port format ip4_match = re.match(r"/ip4/([^/]+)/tcp/(\d+)", multiaddr) if ip4_match: return f"http://{ip4_match.group(1)}:{ip4_match.group(2)}" # Fallback: assume it's already a URL or use default if multiaddr.startswith("http"): return multiaddr return "http://127.0.0.1:5001" class TestMultiaddrConversion: """Tests for multiaddr to URL conversion.""" def test_dns_format(self) -> None: """Docker DNS format should convert correctly.""" result = multiaddr_to_url("/dns/ipfs/tcp/5001") assert result == "http://ipfs:5001" def test_dns4_format(self) -> None: """dns4 format should work.""" result = multiaddr_to_url("/dns4/ipfs.example.com/tcp/5001") assert result == "http://ipfs.example.com:5001" def test_ip4_format(self) -> None: """IPv4 format should convert correctly.""" result = multiaddr_to_url("/ip4/127.0.0.1/tcp/5001") assert result == "http://127.0.0.1:5001" def test_already_url(self) -> None: """HTTP URLs should pass through.""" result = multiaddr_to_url("http://localhost:5001") assert result == "http://localhost:5001" def test_fallback(self) -> None: """Unknown format should fallback to localhost.""" result = multiaddr_to_url("garbage") assert result == "http://127.0.0.1:5001" class TestIPFSConfigConsistency: """ Tests to ensure IPFS configuration is consistent. The effect executor should use IPFS_API (like ipfs_client.py) rather than a separate IPFS_GATEWAY variable. """ def test_effect_module_should_not_use_gateway_var(self) -> None: """ Regression test: Effect module should use IPFS_API, not IPFS_GATEWAY. Bug found 2026-01-12: artdag/nodes/effect.py used IPFS_GATEWAY which defaulted to http://127.0.0.1:8080. This doesn't work in Docker where the IPFS node is a separate container. The ipfs_client.py uses IPFS_API which is correctly set in docker-compose. """ from artdag.nodes import effect # Check if the module still has the old IPFS_GATEWAY variable # After the fix, this should use IPFS_API instead has_gateway_var = hasattr(effect, 'IPFS_GATEWAY') has_api_var = hasattr(effect, 'IPFS_API') or hasattr(effect, '_get_ipfs_base_url') # This test documents the current buggy state # After fix: has_gateway_var should be False, has_api_var should be True if has_gateway_var and not has_api_var: pytest.fail( "Effect module uses IPFS_GATEWAY instead of IPFS_API. " "This breaks Docker deployments where IPFS_API=/dns/ipfs/tcp/5001 " "but IPFS_GATEWAY defaults to localhost." ) def test_ipfs_api_default_is_localhost(self) -> None: """IPFS_API should default to localhost for local development.""" default_api = "/ip4/127.0.0.1/tcp/5001" url = multiaddr_to_url(default_api) assert "127.0.0.1" in url assert "5001" in url def test_docker_ipfs_api_uses_service_name(self) -> None: """In Docker, IPFS_API should use the service name.""" docker_api = "/dns/ipfs/tcp/5001" url = multiaddr_to_url(docker_api) assert url == "http://ipfs:5001" assert "127.0.0.1" not in url class TestEffectFetchURL: """Tests for the URL used to fetch effects from IPFS.""" def test_fetch_should_use_api_cat_endpoint(self) -> None: """ Effect fetch should use /api/v0/cat endpoint (like ipfs_client.py). The IPFS API's cat endpoint works reliably in Docker. The gateway endpoint (port 8080) requires separate configuration. """ # The correct way to fetch via API base_url = "http://ipfs:5001" cid = "QmTestCid123" correct_url = f"{base_url}/api/v0/cat?arg={cid}" assert "/api/v0/cat" in correct_url assert "arg=" in correct_url def test_gateway_url_is_different_from_api(self) -> None: """ Document the difference between gateway and API URLs. Gateway: http://ipfs:8080/ipfs/{cid} (requires IPFS_GATEWAY config) API: http://ipfs:5001/api/v0/cat?arg={cid} (uses IPFS_API config) Using the API is more reliable since IPFS_API is already configured correctly in docker-compose.yml. """ cid = "QmTestCid123" # Gateway style (the old broken way) gateway_url = f"http://ipfs:8080/ipfs/{cid}" # API style (the correct way) api_url = f"http://ipfs:5001/api/v0/cat?arg={cid}" # These are different approaches assert gateway_url != api_url assert ":8080" in gateway_url assert ":5001" in api_url class TestEffectDependencies: """Tests for effect dependency handling.""" def test_parse_pep723_dependencies(self) -> None: """Should parse PEP 723 dependencies from effect source.""" source = ''' # /// script # requires-python = ">=3.10" # dependencies = ["numpy", "opencv-python"] # /// """ @effect test_effect """ def process_frame(frame, params, state): return frame, state ''' # Import the function after the fix is applied from artdag.nodes.effect import _parse_pep723_dependencies deps = _parse_pep723_dependencies(source) assert deps == ["numpy", "opencv-python"] def test_parse_pep723_no_dependencies(self) -> None: """Should return empty list if no dependencies block.""" source = ''' """ @effect simple_effect """ def process_frame(frame, params, state): return frame, state ''' from artdag.nodes.effect import _parse_pep723_dependencies deps = _parse_pep723_dependencies(source) assert deps == [] def test_ensure_dependencies_already_installed(self) -> None: """Should return True if dependencies are already installed.""" from artdag.nodes.effect import _ensure_dependencies # os is always available result = _ensure_dependencies(["os"], "QmTest123") assert result is True def test_effect_with_missing_dependency_gives_clear_error(self, tmp_path: Path) -> None: """ Regression test: Missing dependencies should give clear error message. Bug found 2026-01-12: Effect with numpy dependency failed with "No module named 'numpy'" but this was swallowed and reported as "Unknown effect: invert" - very confusing. """ effects_dir = tmp_path / "_effects" effect_cid = "QmTestEffectWithDeps" # Create effect that imports a non-existent module effect_dir = effects_dir / effect_cid effect_dir.mkdir(parents=True) (effect_dir / "effect.py").write_text(''' # /// script # requires-python = ">=3.10" # dependencies = ["some_nonexistent_package_xyz"] # /// """ @effect test_effect """ import some_nonexistent_package_xyz def process_frame(frame, params, state): return frame, state ''') # The effect file exists effect_path = effects_dir / effect_cid / "effect.py" assert effect_path.exists() # When loading fails due to missing import, error should mention the dependency with patch.dict(os.environ, {"CACHE_DIR": str(tmp_path)}): from artdag.nodes.effect import _load_cached_effect # This should return None but log a clear error about the missing module result = _load_cached_effect(effect_cid) # Currently returns None, which causes "Unknown effect" error # The real issue is the dependency isn't installed assert result is None class TestEffectCacheAndFetch: """Integration tests for effect caching and fetching.""" def test_effect_loads_from_cache_without_ipfs(self, tmp_path: Path) -> None: """When effect is in cache, IPFS should not be contacted.""" effects_dir = tmp_path / "_effects" effect_cid = "QmTestEffect123" # Create cached effect effect_dir = effects_dir / effect_cid effect_dir.mkdir(parents=True) (effect_dir / "effect.py").write_text(''' def process_frame(frame, params, state): return frame, state ''') # Patch environment and verify effect can be loaded with patch.dict(os.environ, {"CACHE_DIR": str(tmp_path)}): from artdag.nodes.effect import _load_cached_effect # Should load without hitting IPFS effect_fn = _load_cached_effect(effect_cid) assert effect_fn is not None def test_effect_fetch_uses_correct_endpoint(self, tmp_path: Path) -> None: """When fetching from IPFS, should use API endpoint.""" effects_dir = tmp_path / "_effects" effects_dir.mkdir(parents=True) effect_cid = "QmNonExistentEffect" with patch.dict(os.environ, { "CACHE_DIR": str(tmp_path), "IPFS_API": "/dns/ipfs/tcp/5001" }): with patch('requests.post') as mock_post: # Set up mock to return effect source mock_response = MagicMock() mock_response.status_code = 200 mock_response.content = b'def process_frame(f, p, s): return f, s' mock_post.return_value = mock_response from artdag.nodes.effect import _load_cached_effect # Try to load - should attempt IPFS fetch _load_cached_effect(effect_cid) # After fix, this should use the API endpoint # Check if requests.post was called (API style) # or requests.get was called (gateway style) # The fix should make it use POST to /api/v0/cat