"""Unit tests for Redis distributed lock.""" from unittest import mock import pytest from utils.redis_lock import redis_lock, get_redis_client def _setup_client(mock_get_client: mock.MagicMock, set_return: object = True) -> mock.MagicMock: """Return a MagicMock redis client wired up for the lock helper.""" mock_client = mock.MagicMock() mock_client.set.return_value = set_return mock_get_client.return_value = mock_client return mock_client class TestRedisLock: """Tests for redis_lock context manager.""" @mock.patch("utils.redis_lock.get_redis_client") def test_lock_acquired_successfully(self, mock_get_client): """Test lock acquisition when no other lock exists.""" mock_client = _setup_client(mock_get_client) with redis_lock("test_lock") as acquired: assert acquired is True # Lock is set with the owner UUID, nx=True, and the configured TTL. assert mock_client.set.call_count == 1 args, kwargs = mock_client.set.call_args assert args[0] == "lock:test_lock" assert isinstance(args[1], str) and len(args[1]) == 32 # uuid4 hex assert kwargs == {"nx": True, "ex": 3600 * 4} # Release happens via register_script (Lua CAS), not raw DEL. mock_client.register_script.assert_called_once() # The script wrapper is called once with the lock key and owner token. release_script = mock_client.register_script.return_value release_script.assert_called_once() call_args = release_script.call_args assert call_args.kwargs["keys"] == ["lock:test_lock"] assert call_args.kwargs["args"][0] == args[1] # same owner token @mock.patch("utils.redis_lock.get_redis_client") def test_lock_not_acquired(self, mock_get_client): """Test lock not acquired when another lock exists.""" # Redis returns None when nx=True fails mock_client = _setup_client(mock_get_client, set_return=None) with redis_lock("test_lock") as acquired: assert acquired is False # Should NOT register or invoke the release script since we didn't acquire. mock_client.register_script.assert_not_called() @mock.patch("utils.redis_lock.get_redis_client") def test_lock_released_on_exception(self, mock_get_client): """Test lock is released even when exception occurs.""" mock_client = _setup_client(mock_get_client) with pytest.raises(ValueError): with redis_lock("test_lock") as acquired: assert acquired is True raise ValueError("Test error") # Lock should still be released via the Lua CAS script. mock_client.register_script.assert_called_once() mock_client.register_script.return_value.assert_called_once() @mock.patch("utils.redis_lock.get_redis_client") def test_custom_timeout(self, mock_get_client): """Test lock with custom timeout.""" mock_client = _setup_client(mock_get_client) with redis_lock("test_lock", timeout=300) as acquired: assert acquired is True # Only one SET call with the configured TTL. args, kwargs = mock_client.set.call_args assert args[0] == "lock:test_lock" assert kwargs == {"nx": True, "ex": 300} @mock.patch("utils.redis_lock.get_redis_client") def test_owner_token_is_unique_per_acquisition(self, mock_get_client): """Each acquisition gets a fresh UUID owner token (fencing token).""" mock_client = _setup_client(mock_get_client) with redis_lock("test_lock"): pass token_first = mock_client.set.call_args[0][1] with redis_lock("test_lock"): pass token_second = mock_client.set.call_args[0][1] assert token_first != token_second @mock.patch("utils.redis_lock.redis") def test_get_redis_client_uses_broker_url(self, mock_redis): """Test Redis client is created from CELERY_BROKER_URL with keepalive.""" with mock.patch.dict("os.environ", {"CELERY_BROKER_URL": "redis://testhost:1234/5"}): get_redis_client() mock_redis.from_url.assert_called_once_with( "redis://testhost:1234/5", decode_responses=True, socket_keepalive=True, health_check_interval=25, )