"""Test for the Cache module.""" from datetime import datetime from decimal import Decimal import json import random import pytest from unittest.mock import call from unittest.mock import MagicMock from unittest.mock import patch from tests.fixtures import fixture_config # To hold the patched cache module cache = None def setup_module(module): """Patch the config module with a fixture.""" # Mock the config module required by the cache module module.patched_config = patch.dict( 'sys.modules', {'config': fixture_config}) module.patched_config.start() # Set a patched cache module with the config above # because the cache module imports config from shared import cache as patched_cache global cache cache = patched_cache def teardown_module(module): """Stop patching.""" module.patched_config.stop() def teardown_function(function): """Flush db.""" cache.redis_client.flushall() def keys(): """ Generate dummy keys. Returns: list: list of keys like ['a:a', 'a:b', 'a:c', 'b:a', 'b:b', etc.] """ seq = ['a', 'b', 'c'] return [':'.join([x, y]) for x in seq for y in seq] def keys_values(): """Generate a list of key/value tuples. Returns: list: list of tuples like i.e. ('a:a', 3), ('a:b', 7), etc. """ return [(k, random.choice(range(1, 10))) for k in keys()] def set_keys(): """Set keys into (Fake) Redis.""" for k, v in keys_values(): cache.redis_client.set(k, v) def test_create_cache_key(): """Test creating a cache key based on UPC.""" upcs = ['123456789123', 123456789] for upc in upcs: assert cache.create_cache_key(upc) == 'upc:{}'.format(upc) def test_delete_upcs(): """Test deleting cache keys for a set of UPCs.""" # Patching other cache functions patched_delete_key = patch.object( cache, 'delete_key', new=MagicMock(wraps=cache.delete_key)) patched_delete_key.start() # Generating dummy UPCs upcs = [str(i) * 12 for i in range(1, 9)] expected_keys_values = [] # Building key/value tuples and setting cache keys for upc in upcs: key = 'upc:{}'.format(upc) value = random.choice(range(1, 10)) expected_keys_values.append((key, value)) cache.redis_client.set(key, value) # Deleting keys num_keys_deleted = cache.delete_upcs(*upcs) # Assertions cache.delete_key.assert_called_once_with( *[k for k, v in expected_keys_values]) assert num_keys_deleted == len(upcs) patched_delete_key.stop() def test_delete_and_set_upc_profit_loss(): """Test deleting and setting cache keys for a set of UPCs.""" # Patching cache.datetime.now() now = datetime.now() patched_datetime = patch('shared.cache.datetime') mocked_datetime = patched_datetime.start() mocked_datetime.now.return_value = now # Patching other cache functions patched_delete_key = patch.object( cache, 'delete_key', new=MagicMock(wraps=cache.delete_key)) patched_set_hash = patch.object( cache, 'set_hash', new=MagicMock(wraps=cache.set_hash)) fake_redis_pipe = cache.redis_client.pipeline() patched_pipe = patch.object( cache.redis_client, 'pipeline', return_value=fake_redis_pipe) patched_delete_key.start() patched_set_hash.start() patched_pipe.start() expected_value = [ {'2015-01-01': {'TL': 55.69, 'DV': 486.15}}, {'2015-02-01': {'TL': 78.11, 'DV': 789.23}}, {'2015-03-01': {'TL': 20.63, 'DV': 133.59}} ] expected_hash = { 'profit_loss.timestamp': now.timestamp(), 'profit_loss': expected_value } expected_hash_encoded = json.dumps({ 'profit_loss.timestamp': now.timestamp(), 'profit_loss': expected_value }).encode('utf-8') upc = '887654321012' # Pre-set upc with some hash value cache.redis_client.hmset(upc, {'hi': 'there'}) # Deleting keys cache.delete_and_set_upc_profit_loss(upc, expected_value) # Assertions cache.delete_key.assert_called_once_with( 'upc:{}'.format(upc), redis_pipe=fake_redis_pipe) cache.set_hash.assert_called_once_with( 'upc:{}'.format(upc), expected_hash, redis_pipe=fake_redis_pipe) assert cache.redis_client.get( 'upc:{}'.format(upc)) == expected_hash_encoded patched_datetime.stop() patched_delete_key.stop() patched_set_hash.stop() patched_pipe.stop() def test_flush_db(): """Test deleting all keys with regular Redis client.""" patcher = patch.object( cache.redis_client, 'flushdb', new=MagicMock(wraps=cache.redis_client.flushdb)) patcher.start() set_keys() # Deleting keys cache.flush_db() # Assertions assert cache.redis_client.flushdb.called assert not cache.redis_client.get(keys()[0]) patcher.stop() def test_delete_key(): """Test deleting a single key with regular Redis client.""" patcher = patch.object( cache.redis_client, 'delete', new=MagicMock(wraps=cache.redis_client.delete)) patcher.start() set_keys() # Adding an unexisting key actual_keys = keys() + ['unexisting'] # Deleting keys num_keys_deleted = cache.delete_key(*actual_keys) # Assertions cache.redis_client.delete.assert_called_with(*actual_keys) assert num_keys_deleted == len(keys()) assert not cache.redis_client.get(actual_keys[0]) patcher.stop() def test_delete_key_pipelined(): """Test deleting a single key with a Redis Pipeline.""" patcher = patch.object( cache.redis_client, 'delete', new=MagicMock(wraps=cache.redis_client.delete)) patcher.start() set_keys() pipe = cache.redis_client.pipeline() # Deleting keys using pipeline (kind of transaction like) for k in keys(): cache.delete_key(k, redis_pipe=pipe) # Execute pipeline num_keys_deleted = sum(pipe.execute()) # Assertions assert cache.redis_client.delete.mock_calls == [ call(arg) for arg in keys()] assert num_keys_deleted == len(keys()) assert not cache.redis_client.get(keys()[0]) patcher.stop() def test_delete_keys(): """Test deleting a set of keys with regular Redis client.""" patcher = patch.object( cache, 'delete_key', new=MagicMock(wraps=cache.delete_key)) patcher.start() # FakeRedis does not support the decode_responses=True param # See https://github.com/jamesls/fakeredis/pull/82 # so, we have to encode the keys # And we make it a set because Redis' "keys [pattern]" command comes # back with keys in random order expected_matching_keys = [str.encode(k) for k in keys() if k[:2] == 'a:'] expected_matching_key_set = set(expected_matching_keys) set_keys() cache.redis_client.set('another_one', 'hello') # Deleting keys matching 'a:*' num_keys_deleted = cache.delete_keys('a:*') # call_args returns (args, kwargs), we want only the args actual_keys = set(cache.delete_key.call_args[0]) # Assertions assert actual_keys == expected_matching_key_set assert num_keys_deleted == len(expected_matching_keys) assert cache.redis_client.get('another_one') == b'hello' assert not cache.redis_client.get(expected_matching_keys[0]) patcher.stop() def test_delete_keys_pipeplined(): """Test deleting a set of keys with a Redis pipeline.""" patcher = patch.object( cache, 'delete_key', new=MagicMock(wraps=cache.delete_key)) patcher.start() # FakeRedis does not support the decode_responses=True param # See https://github.com/jamesls/fakeredis/pull/82 # so, we have to encode the keys # And we make it a set because Redis' "keys [pattern]" command comes # back with keys in random order patterns = ['a:*', 'b:*', 'c:*'] expected_matching_keys = [] for p in patterns: expected_matching_keys.append([ str.encode(k) for k in keys() if k[:2] == p[:2]]) # Making the list of lists above a list of sets expected_matching_key_set = [ set(prefixed_set) for prefixed_set in expected_matching_keys] set_keys() cache.redis_client.set('another_one', 'hello') pipe = cache.redis_client.pipeline() for pattern in patterns: # Deleting keys matching pattern cache.delete_keys(pattern, redis_pipe=pipe) # Execute pipeline num_keys_deleted = sum(pipe.execute()) # call_args_list returns (args, kwargs) for each call # we want only the args actual_keys = [set( cache.delete_key.call_args_list[i][0]) for i, p in enumerate(patterns)] # Assertions for i, p in enumerate(patterns): assert actual_keys[i] == expected_matching_key_set[i] assert num_keys_deleted == len(keys()) assert cache.redis_client.get('another_one') == b'hello' assert not cache.redis_client.get(expected_matching_keys[0][0]) patcher.stop() def test_set_hash(): """ Test setting a cache key with a hash (dict). Regular Redis client version. """ patcher = patch.object( cache.redis_client, 'set', new=MagicMock(wraps=cache.redis_client.set)) patcher.start() test_hash = { 'hello': 'HELLO', 'bye': 'BYEBYE' } test_hash_serialized = json.dumps(test_hash) json_patcher = patch.object(cache.json, 'dumps', new=MagicMock( wraps=cache.json.dumps)) json_patcher.start() was_set = cache.set_hash('some_hash', test_hash) # Assertions cache.json.dumps.assert_called_with( test_hash, default=cache.cache_defaults) cache.redis_client.set.assert_called_with( 'some_hash', test_hash_serialized) assert was_set is True patcher.stop() json_patcher.stop() def test_set_hash_pipelined(): """ Test setting a cache key with a hash (dict). Pipelined Redis client version. """ patcher = patch.object( cache.redis_client, 'set', new=MagicMock(wraps=cache.redis_client.set)) patcher.start() json_patcher = patch.object( cache.json, 'dumps', new=MagicMock(wraps=cache.json.dumps)) json_patcher.start() test_hashes = { 'some_hash': {'hello': 'HELLO', 'bye': 'BYEBYE'}, 'some_other_hash': {'bonjour': 'BONJOUR', 'au_revoir': 'AU_REVOIR'} } test_hashes_serialized = { k: json.dumps(v) for k, v in test_hashes.items()} # list of expected calls to the mock expected_calls = [] pipe = cache.redis_client.pipeline() for hash_key, hash_val in test_hashes.items(): cache.set_hash(hash_key, hash_val, redis_pipe=pipe) expected_calls.append(call(hash_key, test_hashes_serialized[hash_key])) num_hashes_set = sum(pipe.execute()) # Assertions assert cache.redis_client.set.mock_calls == expected_calls assert num_hashes_set == len(test_hashes) patcher.stop() json_patcher.stop() @pytest.mark.parametrize('serialized_json, expected', [ ('{"hello": "there"}', dict(hello='there')), ('{INVALID: _ "there"}', None), (None, None), ('[]', []) ]) def test_get_hash(serialized_json, expected): """Test getting a has value from the cache.""" test_key = 'some_key' json_patcher = patch.object(cache.json, 'loads', new=MagicMock( wraps=cache.json.loads)) patcher = patch.object( cache.redis_client, 'get', new=MagicMock(return_value=serialized_json)) json_patcher.start() patcher.start() actual = cache.get_hash(test_key) # Assertions if serialized_json is not None: cache.json.loads.assert_called_with(serialized_json) cache.redis_client.get.assert_called_with(test_key) assert actual == expected patcher.stop() json_patcher.stop() def test_cache_defaults(): """Test cache defaults.""" result = cache.cache_defaults(Decimal('123.45')) assert isinstance(result, float) assert result == 123.45 class Secret: def __str__(self): return 'the secret sauce is mayo' result = cache.cache_defaults(Secret()) assert isinstance(result, str) assert result == 'the secret sauce is mayo'