""" Tests for effect loading from cache and IPFS. These tests verify that: - Effects can be loaded from the local cache directory - IPFS gateway configuration is correct for Docker environments - The effect executor correctly resolves CIDs from config """ import os import sys import tempfile from pathlib import Path from typing import Any, Dict, Optional from unittest.mock import patch, MagicMock import pytest # Minimal effect loading implementation for testing # This mirrors the logic in artdag/nodes/effect.py def get_effects_cache_dir_impl(env_vars: Dict[str, str]) -> Optional[Path]: """Get the effects cache directory from environment or default.""" for env_var in ["CACHE_DIR", "ARTDAG_CACHE_DIR"]: cache_dir = env_vars.get(env_var) if cache_dir: effects_dir = Path(cache_dir) / "_effects" if effects_dir.exists(): return effects_dir # Try default locations for base in [Path.home() / ".artdag" / "cache", Path("/var/cache/artdag")]: effects_dir = base / "_effects" if effects_dir.exists(): return effects_dir return None def effect_path_for_cid(effects_dir: Path, effect_cid: str) -> Path: """Get the expected path for an effect given its CID.""" return effects_dir / effect_cid / "effect.py" class TestEffectCacheDirectory: """Tests for effect cache directory resolution.""" def test_cache_dir_from_env(self, tmp_path: Path) -> None: """CACHE_DIR env var should determine effects directory.""" effects_dir = tmp_path / "_effects" effects_dir.mkdir(parents=True) env = {"CACHE_DIR": str(tmp_path)} result = get_effects_cache_dir_impl(env) assert result == effects_dir def test_artdag_cache_dir_fallback(self, tmp_path: Path) -> None: """ARTDAG_CACHE_DIR should work as fallback.""" effects_dir = tmp_path / "_effects" effects_dir.mkdir(parents=True) env = {"ARTDAG_CACHE_DIR": str(tmp_path)} result = get_effects_cache_dir_impl(env) assert result == effects_dir def test_no_env_returns_none_if_no_default_exists(self) -> None: """Should return None if no cache directory exists.""" env = {} result = get_effects_cache_dir_impl(env) # Will return None unless default dirs exist # This is expected behavior if result is not None: assert result.exists() class TestEffectPathResolution: """Tests for effect path resolution.""" def test_effect_path_structure(self, tmp_path: Path) -> None: """Effect should be at _effects/{cid}/effect.py.""" effects_dir = tmp_path / "_effects" effect_cid = "QmTestEffect123" path = effect_path_for_cid(effects_dir, effect_cid) assert path == effects_dir / effect_cid / "effect.py" def test_effect_file_exists_after_upload(self, tmp_path: Path) -> None: """After upload, effect.py should exist in the right location.""" effects_dir = tmp_path / "_effects" effect_cid = "QmPWaW5E5WFrmDjT6w8enqvtJhM8c5jvQu7XN1doHA3Z7J" # Simulate effect upload (as done by app/routers/effects.py) effect_dir = effects_dir / effect_cid effect_dir.mkdir(parents=True) effect_source = '''""" @effect invert @version 1.0.0 """ def process_frame(frame, params, state): return 255 - frame, state ''' (effect_dir / "effect.py").write_text(effect_source) # Verify the path structure expected_path = effect_path_for_cid(effects_dir, effect_cid) assert expected_path.exists() assert "process_frame" in expected_path.read_text() class TestIPFSAPIConfiguration: """Tests for IPFS API configuration (consistent across codebase).""" def test_ipfs_api_multiaddr_conversion(self) -> None: """ IPFS_API multiaddr should convert to correct URL. Both ipfs_client.py and artdag/nodes/effect.py now use IPFS_API with multiaddr format for consistency. """ import re def multiaddr_to_url(multiaddr: str) -> str: """Convert multiaddr to URL (same logic as ipfs_client.py).""" dns_match = re.match(r"/dns[46]?/([^/]+)/tcp/(\d+)", multiaddr) if dns_match: return f"http://{dns_match.group(1)}:{dns_match.group(2)}" ip4_match = re.match(r"/ip4/([^/]+)/tcp/(\d+)", multiaddr) if ip4_match: return f"http://{ip4_match.group(1)}:{ip4_match.group(2)}" return "http://127.0.0.1:5001" # Docker config docker_api = "/dns/ipfs/tcp/5001" url = multiaddr_to_url(docker_api) assert url == "http://ipfs:5001" # Local dev config local_api = "/ip4/127.0.0.1/tcp/5001" url = multiaddr_to_url(local_api) assert url == "http://127.0.0.1:5001" def test_all_ipfs_access_uses_api_not_gateway(self) -> None: """ All IPFS access should use IPFS_API (port 5001), not IPFS_GATEWAY (port 8080). Fixed 2026-01-12: artdag/nodes/effect.py was using a separate IPFS_GATEWAY variable. Now it uses IPFS_API like ipfs_client.py for consistency. """ # The API endpoint that both modules use api_endpoint = "/api/v0/cat" # This is correct - using the API assert "api/v0" in api_endpoint # Gateway endpoint would be /ipfs/{cid} - we don't use this anymore gateway_pattern = "/ipfs/" assert gateway_pattern not in api_endpoint class TestEffectExecutorConfigResolution: """Tests for how the effect executor resolves CID from config.""" def test_executor_should_use_cid_key(self) -> None: """ Effect executor must look for 'cid' key in config. The transform_node function sets config["cid"] for effects. The executor must read from the same key. """ config = { "effect": "invert", "cid": "QmPWaW5E5WFrmDjT6w8enqvtJhM8c5jvQu7XN1doHA3Z7J", "intensity": 1.0, } # Simulate executor CID extraction (from artdag/nodes/effect.py:258) effect_cid = config.get("cid") or config.get("hash") assert effect_cid == "QmPWaW5E5WFrmDjT6w8enqvtJhM8c5jvQu7XN1doHA3Z7J" def test_executor_should_not_use_effect_hash(self) -> None: """ Regression test: 'effect_hash' is not a valid config key. Bug found 2026-01-12: transform_node was using config["effect_hash"] but executor only checks config["cid"] or config["hash"]. """ config = { "effect": "invert", "effect_hash": "QmPWaW5E5WFrmDjT6w8enqvtJhM8c5jvQu7XN1doHA3Z7J", } # This simulates the buggy behavior where effect_hash was set # but executor doesn't look for it effect_cid = config.get("cid") or config.get("hash") # The bug: effect_hash is ignored, effect_cid is None assert effect_cid is None, "effect_hash should NOT be recognized" def test_hash_key_is_legacy_fallback(self) -> None: """'hash' key should work as legacy fallback for 'cid'.""" config = { "effect": "invert", "hash": "QmPWaW5E5WFrmDjT6w8enqvtJhM8c5jvQu7XN1doHA3Z7J", } effect_cid = config.get("cid") or config.get("hash") assert effect_cid == "QmPWaW5E5WFrmDjT6w8enqvtJhM8c5jvQu7XN1doHA3Z7J" class TestEffectLoadingIntegration: """Integration tests for complete effect loading path.""" def test_effect_loads_from_cache_when_present(self, tmp_path: Path) -> None: """Effect should load from cache without hitting IPFS.""" effects_dir = tmp_path / "_effects" effect_cid = "QmPWaW5E5WFrmDjT6w8enqvtJhM8c5jvQu7XN1doHA3Z7J" # Create effect file in cache effect_dir = effects_dir / effect_cid effect_dir.mkdir(parents=True) (effect_dir / "effect.py").write_text(''' def process_frame(frame, params, state): """Invert colors.""" return 255 - frame, state ''') # Verify the effect can be found effect_path = effect_path_for_cid(effects_dir, effect_cid) assert effect_path.exists() # Load and verify it has the expected function import importlib.util spec = importlib.util.spec_from_file_location("test_effect", effect_path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) assert hasattr(module, "process_frame") def test_effect_fetch_uses_ipfs_api(self, tmp_path: Path) -> None: """Effect fetch should use IPFS API endpoint, not gateway.""" import re def multiaddr_to_url(multiaddr: str) -> str: dns_match = re.match(r"/dns[46]?/([^/]+)/tcp/(\d+)", multiaddr) if dns_match: return f"http://{dns_match.group(1)}:{dns_match.group(2)}" ip4_match = re.match(r"/ip4/([^/]+)/tcp/(\d+)", multiaddr) if ip4_match: return f"http://{ip4_match.group(1)}:{ip4_match.group(2)}" return "http://127.0.0.1:5001" # In Docker, IPFS_API=/dns/ipfs/tcp/5001 docker_multiaddr = "/dns/ipfs/tcp/5001" base_url = multiaddr_to_url(docker_multiaddr) effect_cid = "QmTestCid123" # Should use API endpoint api_url = f"{base_url}/api/v0/cat?arg={effect_cid}" assert "ipfs:5001" in api_url assert "/api/v0/cat" in api_url assert "127.0.0.1" not in api_url class TestSharedVolumeScenario: """ Tests simulating the Docker shared volume scenario. In Docker: - l1-server uploads effect to /data/cache/_effects/{cid}/effect.py - l1-worker should find it at the same path via shared volume """ def test_effect_visible_on_shared_volume(self, tmp_path: Path) -> None: """Effect uploaded on server should be visible to worker.""" # Simulate shared volume mounted at /data/cache on both containers shared_volume = tmp_path / "data" / "cache" effects_dir = shared_volume / "_effects" # Server uploads effect effect_cid = "QmPWaW5E5WFrmDjT6w8enqvtJhM8c5jvQu7XN1doHA3Z7J" effect_upload_dir = effects_dir / effect_cid effect_upload_dir.mkdir(parents=True) (effect_upload_dir / "effect.py").write_text('def process_frame(f, p, s): return f, s') (effect_upload_dir / "metadata.json").write_text('{"cid": "' + effect_cid + '"}') # Worker should find the effect env_vars = {"CACHE_DIR": str(shared_volume)} worker_effects_dir = get_effects_cache_dir_impl(env_vars) assert worker_effects_dir is not None assert worker_effects_dir == effects_dir worker_effect_path = effect_path_for_cid(worker_effects_dir, effect_cid) assert worker_effect_path.exists() def test_effect_cid_matches_registry(self, tmp_path: Path) -> None: """CID in recipe registry must match the uploaded effect directory name.""" shared_volume = tmp_path effects_dir = shared_volume / "_effects" # The CID used in the recipe registry registry_cid = "QmPWaW5E5WFrmDjT6w8enqvtJhM8c5jvQu7XN1doHA3Z7J" # Upload creates directory with CID as name effect_upload_dir = effects_dir / registry_cid effect_upload_dir.mkdir(parents=True) (effect_upload_dir / "effect.py").write_text('def process_frame(f, p, s): return f, s') # Executor receives the same CID from DAG config dag_config_cid = registry_cid # This comes from transform_node # These must match for the lookup to work assert dag_config_cid == registry_cid # And the path must exist lookup_path = effects_dir / dag_config_cid / "effect.py" assert lookup_path.exists()