diff --git a/docker-compose.yml b/docker-compose.yml index 59b18c7..01aa1a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,7 @@ services: environment: - REDIS_URL=redis://redis:6379/5 - DATABASE_URL=postgresql://artdag:artdag@postgres:5432/artdag + # IPFS_API multiaddr - used for all IPFS operations (add, cat, pin) - IPFS_API=/dns/ipfs/tcp/5001 - CACHE_DIR=/data/cache # Set IPFS_PRIMARY=true to use IPFS-primary mode (everything on IPFS, no local cache) @@ -78,6 +79,7 @@ services: environment: - REDIS_URL=redis://redis:6379/5 - DATABASE_URL=postgresql://artdag:artdag@postgres:5432/artdag + # IPFS_API multiaddr - used for all IPFS operations (add, cat, pin) - IPFS_API=/dns/ipfs/tcp/5001 - CACHE_DIR=/data/cache - C_FORCE_ROOT=true diff --git a/tests/test_effect_loading.py b/tests/test_effect_loading.py new file mode 100644 index 0000000..3e41467 --- /dev/null +++ b/tests/test_effect_loading.py @@ -0,0 +1,327 @@ +""" +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()