Implements Redis-backed caching with category-aware TTL management: - Immutable categories (artifact metadata, dependencies) cached forever - Mutable categories (index pages, upstream sources) use configurable TTL - Graceful fallback when Redis unavailable or disabled - Pattern-based invalidation for bulk cache clearing
375 lines
12 KiB
Python
375 lines
12 KiB
Python
"""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
|
|
|