Files
gh-christianlouis-docuelevate/tests/test_cache.py
T
2026-03-01 14:41:29 +00:00

213 lines
7.3 KiB
Python

"""
Tests for the Redis caching utility module.
"""
import json
from unittest.mock import MagicMock, patch
import pytest
from app.utils.cache import (
_KEY_PREFIX,
cache_delete,
cache_delete_pattern,
cache_get,
cache_set,
)
@pytest.fixture(autouse=True)
def _reset_redis_client():
"""Reset the module-level Redis client between tests."""
import app.utils.cache as cache_mod
cache_mod._redis_client = None
yield
cache_mod._redis_client = None
# ---------------------------------------------------------------------------
# cache_get
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_cache_get_returns_none_when_redis_unavailable():
"""cache_get should return None when Redis is not reachable."""
with patch("app.utils.cache._get_redis", return_value=None):
assert cache_get("some_key") is None
@pytest.mark.unit
def test_cache_get_returns_none_on_miss():
"""cache_get should return None when the key does not exist."""
mock_client = MagicMock()
mock_client.get.return_value = None
with patch("app.utils.cache._get_redis", return_value=mock_client):
assert cache_get("nonexistent") is None
mock_client.get.assert_called_once_with(f"{_KEY_PREFIX}nonexistent")
@pytest.mark.unit
def test_cache_get_returns_deserialized_value():
"""cache_get should deserialize the stored JSON string."""
mock_client = MagicMock()
mock_client.get.return_value = json.dumps(["application/pdf", "image/png"])
with patch("app.utils.cache._get_redis", return_value=mock_client):
result = cache_get("mime_types")
assert result == ["application/pdf", "image/png"]
@pytest.mark.unit
def test_cache_get_returns_none_on_exception():
"""cache_get should not raise when Redis throws an error."""
mock_client = MagicMock()
mock_client.get.side_effect = Exception("connection lost")
with patch("app.utils.cache._get_redis", return_value=mock_client):
assert cache_get("key") is None
# ---------------------------------------------------------------------------
# cache_set
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_cache_set_stores_json_with_ttl():
"""cache_set should serialise value as JSON and set with TTL."""
mock_client = MagicMock()
with patch("app.utils.cache._get_redis", return_value=mock_client):
cache_set("my_key", {"a": 1}, ttl=60)
mock_client.setex.assert_called_once_with(f"{_KEY_PREFIX}my_key", 60, json.dumps({"a": 1}))
@pytest.mark.unit
def test_cache_set_uses_default_ttl():
"""cache_set should default to 300 seconds TTL."""
mock_client = MagicMock()
with patch("app.utils.cache._get_redis", return_value=mock_client):
cache_set("key", "val")
_, args, _ = mock_client.setex.mock_calls[0]
assert args[1] == 300
@pytest.mark.unit
def test_cache_set_noop_when_redis_unavailable():
"""cache_set should silently do nothing when Redis is down."""
with patch("app.utils.cache._get_redis", return_value=None):
cache_set("key", "val") # Should not raise
@pytest.mark.unit
def test_cache_set_ignores_exception():
"""cache_set should not raise on Redis errors."""
mock_client = MagicMock()
mock_client.setex.side_effect = Exception("write failed")
with patch("app.utils.cache._get_redis", return_value=mock_client):
cache_set("key", "val") # Should not raise
# ---------------------------------------------------------------------------
# cache_delete
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_cache_delete_removes_key():
"""cache_delete should delete the prefixed key."""
mock_client = MagicMock()
with patch("app.utils.cache._get_redis", return_value=mock_client):
cache_delete("old_key")
mock_client.delete.assert_called_once_with(f"{_KEY_PREFIX}old_key")
@pytest.mark.unit
def test_cache_delete_noop_when_redis_unavailable():
"""cache_delete should silently do nothing when Redis is down."""
with patch("app.utils.cache._get_redis", return_value=None):
cache_delete("key") # Should not raise
@pytest.mark.unit
def test_cache_delete_ignores_exception():
"""cache_delete should not raise on Redis errors."""
mock_client = MagicMock()
mock_client.delete.side_effect = Exception("delete failed")
with patch("app.utils.cache._get_redis", return_value=mock_client):
cache_delete("key") # Should not raise
# ---------------------------------------------------------------------------
# cache_delete_pattern
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_cache_delete_pattern_scans_and_deletes():
"""cache_delete_pattern should use SCAN to find and delete matching keys."""
mock_client = MagicMock()
# Simulate SCAN returning keys in one batch then cursor 0
mock_client.scan.return_value = (0, [f"{_KEY_PREFIX}mime_types", f"{_KEY_PREFIX}mime_list"])
with patch("app.utils.cache._get_redis", return_value=mock_client):
cache_delete_pattern("mime_*")
mock_client.scan.assert_called_once_with(0, match=f"{_KEY_PREFIX}mime_*", count=100)
mock_client.delete.assert_called_once_with(f"{_KEY_PREFIX}mime_types", f"{_KEY_PREFIX}mime_list")
@pytest.mark.unit
def test_cache_delete_pattern_noop_when_redis_unavailable():
"""cache_delete_pattern should silently do nothing when Redis is down."""
with patch("app.utils.cache._get_redis", return_value=None):
cache_delete_pattern("mime_*") # Should not raise
@pytest.mark.unit
def test_cache_delete_pattern_handles_empty_scan():
"""cache_delete_pattern should not call delete when SCAN returns no keys."""
mock_client = MagicMock()
mock_client.scan.return_value = (0, [])
with patch("app.utils.cache._get_redis", return_value=mock_client):
cache_delete_pattern("none_*")
mock_client.delete.assert_not_called()
@pytest.mark.unit
def test_cache_delete_pattern_ignores_exception():
"""cache_delete_pattern should not raise on Redis errors."""
mock_client = MagicMock()
mock_client.scan.side_effect = Exception("scan failed")
with patch("app.utils.cache._get_redis", return_value=mock_client):
cache_delete_pattern("x*") # Should not raise
# ---------------------------------------------------------------------------
# _get_redis
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_get_redis_returns_none_on_connection_failure():
"""_get_redis should return None when Redis connection fails."""
import app.utils.cache as cache_mod
with patch("app.utils.cache.redis.from_url", side_effect=Exception("refused")):
result = cache_mod._get_redis()
assert result is None
@pytest.mark.unit
def test_get_redis_caches_client():
"""_get_redis should reuse the cached client on subsequent calls."""
import app.utils.cache as cache_mod
mock_client = MagicMock()
mock_client.ping.return_value = True
with patch("app.utils.cache.redis.from_url", return_value=mock_client):
first = cache_mod._get_redis()
second = cache_mod._get_redis()
assert first is second
# from_url should only have been called once
assert first is mock_client