yusufcalisir's picture
deploy: Hugging Face space upload
73ba4f5
Raw History Blame Contribute Delete
4.76 kB
"""Unit and integration tests for Section 35.2 — Cache Layer & Graceful Degradation.
Tests:
1. CacheService initialization & singleton instance.
2. Read-aside and Write-through operations for Alerts, Cases, and Entities.
3. TTL expiration logic & key formatting.
4. Graceful degradation when Redis is offline (all methods fall back gracefully without raising exceptions).
5. test_cache_miss_falls_back_to_db
6. test_cache_hit_skips_db
7. test_cache_invalidation_on_write
"""
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from app.infrastructure.cache import (
TTL_ALERT,
TTL_CASE,
TTL_ENTITY,
CacheService,
write_through_cache,
)
@pytest.fixture
def cache_service():
"""Reset CacheService singleton state for testing."""
CacheService._instance = None
CacheService._client = None
CacheService._unavailable = False
return CacheService.get()
@pytest.mark.asyncio
async def test_cache_service_singleton(cache_service: CacheService) -> None:
"""CacheService.get() must return the same process-level singleton instance."""
instance1 = CacheService.get()
instance2 = CacheService.get()
assert instance1 is instance2
@pytest.mark.asyncio
async def test_graceful_degradation_when_redis_unavailable(cache_service: CacheService) -> None:
"""When Redis is unavailable or unconfigured, all methods must return None/default without raising exceptions."""
# Force unavailable state
CacheService._unavailable = True
# Reads should return None
assert await cache_service.get_alert("alert-123") is None
assert await cache_service.get_case("case-123") is None
assert await cache_service.get_entity("entity-123") is None
assert await cache_service.get_entity_id_by_hash("hash-123") is None
# Writes & invalidations should fail silently
await cache_service.set_alert("alert-123", {"id": "alert-123", "status": "new"})
await cache_service.invalidate_alert("alert-123", "bank_alpha")
await cache_service.set_case("case-123", {"id": "case-123"})
await cache_service.set_entity("entity-123", {"id": "entity-123", "privacy_id": "p123"})
# Health check should return False
assert await cache_service.health() is False
# Rate limiter enforces sliding window locally when Redis is down without failing open
assert await cache_service.rate_limit_check("test-ip", limit=5) is True
for _ in range(4):
assert await cache_service.rate_limit_check("test-ip", limit=5) is True
# 6th request must be rejected under strict rate limiting
assert await cache_service.rate_limit_check("test-ip", limit=5) is False
@pytest.mark.asyncio
async def test_ttl_constants() -> None:
"""Verify production TTL configurations align with design spec."""
assert TTL_ALERT == 300
assert TTL_CASE == 300
assert TTL_ENTITY == 600
@pytest.mark.asyncio
async def test_cache_miss_falls_back_to_db(cache_service: CacheService) -> None:
"""Mock Redis miss -> assert DB function is called -> assert result returned."""
mock_db_func = AsyncMock(return_value={"id": "alert-999", "status": "new"})
@write_through_cache(ttl_seconds=300, key_prefix="alert")
async def get_alert_from_db(self, alert_id: str):
return await mock_db_func(alert_id)
# Force cache miss
CacheService._unavailable = True
res = await get_alert_from_db(None, "alert-999")
assert res == {"id": "alert-999", "status": "new"}
mock_db_func.assert_called_once_with("alert-999")
@pytest.mark.asyncio
async def test_cache_hit_skips_db(cache_service: CacheService) -> None:
"""When cache has data, DB function must NOT be called."""
mock_db_func = AsyncMock(return_value={"id": "alert-888", "status": "new"})
@write_through_cache(ttl_seconds=300, key_prefix="alert")
async def get_alert_from_db(self, alert_id: str):
return await mock_db_func(alert_id)
# Mock Redis client get to return cached data
mock_redis = AsyncMock()
mock_redis.get.return_value = '{"id": "alert-888", "status": "cached"}'
CacheService._client = mock_redis
CacheService._unavailable = False
res = await get_alert_from_db(None, "alert-888")
assert res == {"id": "alert-888", "status": "cached"}
mock_db_func.assert_not_called()
@pytest.mark.asyncio
async def test_cache_invalidation_on_write(cache_service: CacheService) -> None:
"""Invalidate alert must delete key from Redis."""
mock_redis = AsyncMock()
CacheService._client = mock_redis
CacheService._unavailable = False
await cache_service.invalidate_alert("alert-777", "bank_alpha")
mock_redis.delete.assert_called_once_with("alert:alert-777", "alerts:bank:bank_alpha")