"""Tests for CacheService.""" import pytest from unittest.mock import MagicMock, AsyncMock, patch class TestCacheCategory: """Tests for cache category enum.""" @pytest.mark.unit def test_immutable_categories_have_no_ttl(self): """Immutable categories should return None for TTL.""" from app.cache_service import CacheCategory, get_category_ttl from app.config import Settings settings = Settings() assert get_category_ttl(CacheCategory.ARTIFACT_METADATA, settings) is None assert get_category_ttl(CacheCategory.ARTIFACT_DEPENDENCIES, settings) is None assert get_category_ttl(CacheCategory.DEPENDENCY_RESOLUTION, settings) is None @pytest.mark.unit def test_mutable_categories_have_ttl(self): """Mutable categories should return configured TTL.""" from app.cache_service import CacheCategory, get_category_ttl from app.config import Settings settings = Settings( cache_ttl_index=300, cache_ttl_upstream=3600, ) assert get_category_ttl(CacheCategory.PACKAGE_INDEX, settings) == 300 assert get_category_ttl(CacheCategory.UPSTREAM_SOURCES, settings) == 3600 class TestCacheService: """Tests for Redis cache service.""" @pytest.mark.asyncio @pytest.mark.unit async def test_disabled_cache_returns_none(self): """When Redis disabled, get() should return None.""" from app.cache_service import CacheService, CacheCategory from app.config import Settings settings = Settings(redis_enabled=False) cache = CacheService(settings) await cache.startup() result = await cache.get(CacheCategory.PACKAGE_INDEX, "test-key") assert result is None await cache.shutdown() @pytest.mark.asyncio @pytest.mark.unit async def test_disabled_cache_set_is_noop(self): """When Redis disabled, set() should be a no-op.""" from app.cache_service import CacheService, CacheCategory from app.config import Settings settings = Settings(redis_enabled=False) cache = CacheService(settings) await cache.startup() # Should not raise await cache.set(CacheCategory.PACKAGE_INDEX, "test-key", b"test-value") await cache.shutdown() @pytest.mark.asyncio @pytest.mark.unit async def test_cache_key_namespacing(self): """Cache keys should be properly namespaced.""" from app.cache_service import CacheService, CacheCategory key = CacheService._make_key(CacheCategory.PACKAGE_INDEX, "pypi", "numpy") assert key == "orchard:index:pypi:numpy" @pytest.mark.asyncio @pytest.mark.unit async def test_ping_returns_false_when_disabled(self): """ping() should return False when Redis is disabled.""" from app.cache_service import CacheService from app.config import Settings settings = Settings(redis_enabled=False) cache = CacheService(settings) await cache.startup() result = await cache.ping() assert result is False await cache.shutdown() @pytest.mark.asyncio @pytest.mark.unit async def test_enabled_property(self): """enabled property should reflect Redis state.""" from app.cache_service import CacheService from app.config import Settings settings = Settings(redis_enabled=False) cache = CacheService(settings) assert cache.enabled is False @pytest.mark.asyncio @pytest.mark.unit async def test_delete_is_noop_when_disabled(self): """delete() should be a no-op when Redis is disabled.""" from app.cache_service import CacheService, CacheCategory from app.config import Settings settings = Settings(redis_enabled=False) cache = CacheService(settings) await cache.startup() # Should not raise await cache.delete(CacheCategory.PACKAGE_INDEX, "test-key") await cache.shutdown() @pytest.mark.asyncio @pytest.mark.unit async def test_invalidate_pattern_returns_zero_when_disabled(self): """invalidate_pattern() should return 0 when Redis is disabled.""" from app.cache_service import CacheService, CacheCategory from app.config import Settings settings = Settings(redis_enabled=False) cache = CacheService(settings) await cache.startup() result = await cache.invalidate_pattern(CacheCategory.PACKAGE_INDEX) assert result == 0 await cache.shutdown() @pytest.mark.asyncio @pytest.mark.unit async def test_startup_already_started(self): """startup() should be idempotent.""" from app.cache_service import CacheService from app.config import Settings settings = Settings(redis_enabled=False) cache = CacheService(settings) await cache.startup() await cache.startup() # Should not raise assert cache._started is True await cache.shutdown() @pytest.mark.asyncio @pytest.mark.unit async def test_shutdown_not_started(self): """shutdown() should handle not-started state.""" from app.cache_service import CacheService from app.config import Settings settings = Settings(redis_enabled=False) cache = CacheService(settings) # Should not raise await cache.shutdown() @pytest.mark.asyncio @pytest.mark.unit async def test_make_key_with_default_protocol(self): """_make_key should work with default protocol.""" from app.cache_service import CacheService, CacheCategory key = CacheService._make_key(CacheCategory.ARTIFACT_METADATA, "default", "abc123") assert key == "orchard:artifact:default:abc123" class TestCacheServiceWithMockedRedis: """Tests for CacheService with mocked Redis client.""" @pytest.mark.asyncio @pytest.mark.unit async def test_get_returns_cached_value(self): """get() should return cached value when available.""" from app.cache_service import CacheService, CacheCategory from app.config import Settings settings = Settings(redis_enabled=True) cache = CacheService(settings) # Mock the redis client mock_redis = AsyncMock() mock_redis.get.return_value = b"cached-data" cache._redis = mock_redis cache._enabled = True cache._started = True result = await cache.get(CacheCategory.PACKAGE_INDEX, "test-key", "pypi") assert result == b"cached-data" mock_redis.get.assert_called_once_with("orchard:index:pypi:test-key") @pytest.mark.asyncio @pytest.mark.unit async def test_set_with_ttl(self): """set() should use setex for mutable categories.""" from app.cache_service import CacheService, CacheCategory from app.config import Settings settings = Settings(redis_enabled=True, cache_ttl_index=300) cache = CacheService(settings) mock_redis = AsyncMock() cache._redis = mock_redis cache._enabled = True cache._started = True await cache.set(CacheCategory.PACKAGE_INDEX, "test-key", b"test-value", "pypi") mock_redis.setex.assert_called_once_with( "orchard:index:pypi:test-key", 300, b"test-value" ) @pytest.mark.asyncio @pytest.mark.unit async def test_set_without_ttl(self): """set() should use set (no expiry) for immutable categories.""" from app.cache_service import CacheService, CacheCategory from app.config import Settings settings = Settings(redis_enabled=True) cache = CacheService(settings) mock_redis = AsyncMock() cache._redis = mock_redis cache._enabled = True cache._started = True await cache.set( CacheCategory.ARTIFACT_METADATA, "abc123", b"metadata", "pypi" ) mock_redis.set.assert_called_once_with( "orchard:artifact:pypi:abc123", b"metadata" ) @pytest.mark.asyncio @pytest.mark.unit async def test_delete_calls_redis_delete(self): """delete() should call Redis delete.""" from app.cache_service import CacheService, CacheCategory from app.config import Settings settings = Settings(redis_enabled=True) cache = CacheService(settings) mock_redis = AsyncMock() cache._redis = mock_redis cache._enabled = True cache._started = True await cache.delete(CacheCategory.PACKAGE_INDEX, "test-key", "pypi") mock_redis.delete.assert_called_once_with("orchard:index:pypi:test-key") @pytest.mark.asyncio @pytest.mark.unit async def test_invalidate_pattern_deletes_matching_keys(self): """invalidate_pattern() should delete all matching keys.""" from app.cache_service import CacheService, CacheCategory from app.config import Settings settings = Settings(redis_enabled=True) cache = CacheService(settings) mock_redis = AsyncMock() # Create an async generator for scan_iter async def mock_scan_iter(match=None): for key in [b"orchard:index:pypi:numpy", b"orchard:index:pypi:requests"]: yield key mock_redis.scan_iter = mock_scan_iter mock_redis.delete.return_value = 2 cache._redis = mock_redis cache._enabled = True cache._started = True result = await cache.invalidate_pattern(CacheCategory.PACKAGE_INDEX, "*", "pypi") assert result == 2 mock_redis.delete.assert_called_once() @pytest.mark.asyncio @pytest.mark.unit async def test_ping_returns_true_when_connected(self): """ping() should return True when Redis responds.""" from app.cache_service import CacheService from app.config import Settings settings = Settings(redis_enabled=True) cache = CacheService(settings) mock_redis = AsyncMock() mock_redis.ping.return_value = True cache._redis = mock_redis cache._enabled = True cache._started = True result = await cache.ping() assert result is True @pytest.mark.asyncio @pytest.mark.unit async def test_get_handles_exception(self): """get() should return None and log warning on exception.""" from app.cache_service import CacheService, CacheCategory from app.config import Settings settings = Settings(redis_enabled=True) cache = CacheService(settings) mock_redis = AsyncMock() mock_redis.get.side_effect = Exception("Connection lost") cache._redis = mock_redis cache._enabled = True cache._started = True result = await cache.get(CacheCategory.PACKAGE_INDEX, "test-key") assert result is None @pytest.mark.asyncio @pytest.mark.unit async def test_set_handles_exception(self): """set() should log warning on exception.""" from app.cache_service import CacheService, CacheCategory from app.config import Settings settings = Settings(redis_enabled=True, cache_ttl_index=300) cache = CacheService(settings) mock_redis = AsyncMock() mock_redis.setex.side_effect = Exception("Connection lost") cache._redis = mock_redis cache._enabled = True cache._started = True # Should not raise await cache.set(CacheCategory.PACKAGE_INDEX, "test-key", b"value") @pytest.mark.asyncio @pytest.mark.unit async def test_ping_returns_false_on_exception(self): """ping() should return False on exception.""" from app.cache_service import CacheService from app.config import Settings settings = Settings(redis_enabled=True) cache = CacheService(settings) mock_redis = AsyncMock() mock_redis.ping.side_effect = Exception("Connection lost") cache._redis = mock_redis cache._enabled = True cache._started = True result = await cache.ping() assert result is False