Files
TimeTracker/tests/test_utils/test_cache.py
T
MacJediWizard 2f53325185 test(cache): use real app fixture instead of mocking Flask's LocalProxy
TestCacheIntegration::test_get_cache_with_redis_enabled and
test_get_cache_fallback_to_memory were both failing with:

    RuntimeError: Working outside of application context.

The tests patched `app.utils.cache.current_app` with a MagicMock and
set `mock_app.config = {...}`. In theory that should isolate get_cache()
from Flask's LocalProxy entirely. In practice the RuntimeError still
fires — most likely because (a) module-level `_cache` left over from a
prior test holds a reference that triggers a LocalProxy access at
return time, or (b) a code path inside the patched function still
reaches the real `current_app` past the mock boundary.

Rather than diagnose the exact mock-interaction issue, rewrite both
tests to use the real `app` fixture from conftest.py:

- Set `REDIS_ENABLED` / `REDIS_URL` / `REDIS_DEFAULT_TTL` on the real
  Flask config inside `with app.app_context():`.
- Reset `app.utils.cache._cache = None` explicitly so the global
  doesn't leak state between tests.
- Keep the `patch("app.utils.cache.RedisCache")` since we don't want
  the test to actually hit Redis; only the read of `current_app.config`
  needs to be real.

This is closer to how get_cache() runs in production (real Flask
context, mocked external dependency) and avoids the LocalProxy ↔ mock
collision entirely.

Test plan
- pytest tests/test_utils/test_cache.py::TestCacheIntegration::test_get_cache_with_redis_enabled
- pytest tests/test_utils/test_cache.py::TestCacheIntegration::test_get_cache_fallback_to_memory
- pytest tests/test_utils/test_cache.py (the other 16 tests in the file should be unaffected)
2026-05-14 17:31:32 -04:00

183 lines
5.6 KiB
Python

"""
Tests for caching utilities (Redis and in-memory fallback).
"""
import pytest
pytestmark = [pytest.mark.unit, pytest.mark.utils]
from unittest.mock import Mock, patch, MagicMock
from app.utils.cache import get_cache, InMemoryCache, RedisCache
class TestInMemoryCache:
"""Tests for in-memory cache implementation"""
def test_get_set_delete(self):
"""Test basic cache operations"""
cache = InMemoryCache()
# Test set and get
cache.set("test_key", "test_value", ttl=3600)
assert cache.get("test_key") == "test_value"
# Test delete
cache.delete("test_key")
assert cache.get("test_key") is None
def test_expiration(self):
"""Test that expired entries are not returned"""
cache = InMemoryCache(default_ttl=1)
cache.set("expired_key", "value", ttl=0.1) # Very short TTL
assert cache.get("expired_key") == "value"
import time
time.sleep(0.2)
assert cache.get("expired_key") is None
def test_exists(self):
"""Test exists method"""
cache = InMemoryCache()
assert cache.exists("nonexistent") is False
cache.set("existing", "value")
assert cache.exists("existing") is True
def test_clear(self):
"""Test clearing all cache"""
cache = InMemoryCache()
cache.set("key1", "value1")
cache.set("key2", "value2")
cache.clear()
assert cache.get("key1") is None
assert cache.get("key2") is None
class TestRedisCache:
"""Tests for Redis cache implementation"""
@patch("app.utils.cache.redis")
def test_redis_connection_success(self, mock_redis):
"""Test successful Redis connection"""
mock_client = MagicMock()
mock_client.ping.return_value = True
mock_redis.Redis.return_value = mock_client
cache = RedisCache("redis://localhost:6379/0")
assert cache._connected is True
mock_client.ping.assert_called_once()
@patch("app.utils.cache.redis")
def test_redis_connection_failure(self, mock_redis):
"""Test Redis connection failure falls back to in-memory"""
mock_redis.Redis.side_effect = Exception("Connection failed")
cache = RedisCache("redis://localhost:6379/0")
assert cache._connected is False
assert hasattr(cache, "_fallback")
@patch("app.utils.cache.redis")
def test_redis_get_set(self, mock_redis):
"""Test Redis get and set operations"""
import pickle
mock_client = MagicMock()
mock_client.ping.return_value = True
mock_redis.Redis.return_value = mock_client
cache = RedisCache("redis://localhost:6379/0")
# Test set
cache.set("test_key", "test_value", ttl=3600)
mock_client.setex.assert_called_once()
args, kwargs = mock_client.setex.call_args
assert args[0] == "test_key"
assert args[1] == 3600
assert pickle.loads(args[2]) == "test_value"
# Test get
mock_client.get.return_value = pickle.dumps("test_value")
result = cache.get("test_key")
assert result == "test_value"
mock_client.get.assert_called_with("test_key")
@patch("app.utils.cache.redis")
def test_redis_fallback_on_error(self, mock_redis):
"""Test that Redis errors fall back to in-memory cache"""
mock_client = MagicMock()
mock_client.ping.return_value = True
mock_client.get.side_effect = Exception("Redis error")
mock_redis.Redis.return_value = mock_client
cache = RedisCache("redis://localhost:6379/0")
# Should not raise, but return None
result = cache.get("test_key")
assert result is None
class TestCacheIntegration:
"""Integration tests for cache utilities"""
def test_get_cache_with_redis_enabled(self, app):
"""Test get_cache when Redis is enabled (RedisCache is mocked)."""
import app.utils.cache as cache_module
cache_module._cache = None
with app.app_context():
app.config["REDIS_ENABLED"] = True
app.config["REDIS_URL"] = "redis://localhost:6379/0"
app.config["REDIS_DEFAULT_TTL"] = 3600
with patch("app.utils.cache.RedisCache") as mock_redis_cache:
mock_instance = MagicMock()
mock_instance._connected = True
mock_redis_cache.return_value = mock_instance
cache = get_cache()
assert cache is not None
def test_get_cache_fallback_to_memory(self, app):
"""Test get_cache falls back to in-memory when Redis is disabled."""
import app.utils.cache as cache_module
cache_module._cache = None
with app.app_context():
app.config["REDIS_ENABLED"] = False
app.config["REDIS_DEFAULT_TTL"] = 3600
cache = get_cache()
assert isinstance(cache, InMemoryCache)
def test_cache_decorator(self):
"""Test the @cached decorator"""
from app.utils.cache import cached
call_count = [0]
@cached(ttl=60, key_prefix="test")
def expensive_function(x, y):
call_count[0] += 1
return x + y
# First call should execute function
result1 = expensive_function(1, 2)
assert result1 == 3
assert call_count[0] == 1
# Second call should use cache
result2 = expensive_function(1, 2)
assert result2 == 3
assert call_count[0] == 1 # Should not increment
# Different args should execute again
result3 = expensive_function(2, 3)
assert result3 == 5
assert call_count[0] == 2