4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
754 lines
30 KiB
Python
754 lines
30 KiB
Python
"""Unit tests for Redis integration."""
|
|
|
|
import os
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from integrations.catalog import classify_integrations as _classify_integrations
|
|
from integrations.redis import (
|
|
RedisConfig,
|
|
build_redis_config,
|
|
get_client_list,
|
|
get_latency_doctor,
|
|
get_list_depth,
|
|
get_replication,
|
|
get_server_info,
|
|
get_slowlog,
|
|
redis_config_from_env,
|
|
redis_extract_params,
|
|
scan_keys,
|
|
validate_redis_config,
|
|
)
|
|
|
|
|
|
class TestRedisConfig:
|
|
def test_default_values(self):
|
|
config = RedisConfig(host="localhost")
|
|
assert config.port == 6379
|
|
assert config.username == ""
|
|
assert config.password == ""
|
|
assert config.db == 0
|
|
assert config.ssl is False
|
|
assert config.timeout_seconds == 5.0
|
|
assert config.max_results == 50
|
|
|
|
def test_normalization(self):
|
|
config = RedisConfig(host=" localhost ", username=" acl ", password=" hunter2 ")
|
|
assert config.host == "localhost"
|
|
assert config.username == "acl"
|
|
assert config.password == "hunter2"
|
|
|
|
def test_is_configured(self):
|
|
assert RedisConfig(host="localhost").is_configured is True
|
|
assert RedisConfig(host="").is_configured is False
|
|
|
|
|
|
class TestRedisBuild:
|
|
def test_build_redis_config(self):
|
|
raw = {
|
|
"host": "cache.example.net",
|
|
"port": 6380,
|
|
"username": "monitor",
|
|
"password": "p",
|
|
"db": 3,
|
|
"ssl": True,
|
|
}
|
|
config = build_redis_config(raw)
|
|
assert config.host == "cache.example.net"
|
|
assert config.port == 6380
|
|
assert config.username == "monitor"
|
|
assert config.password == "p"
|
|
assert config.db == 3
|
|
assert config.ssl is True
|
|
|
|
@patch.dict(
|
|
os.environ,
|
|
{
|
|
"REDIS_HOST": "env-host",
|
|
"REDIS_PORT": "6380",
|
|
"REDIS_USERNAME": "env-user",
|
|
"REDIS_PASSWORD": "env-pass",
|
|
"REDIS_DATABASE": "2",
|
|
"REDIS_SSL": "true",
|
|
},
|
|
)
|
|
def test_redis_config_from_env(self):
|
|
config = redis_config_from_env()
|
|
assert config is not None
|
|
assert config.host == "env-host"
|
|
assert config.port == 6380
|
|
assert config.username == "env-user"
|
|
assert config.password == "env-pass"
|
|
assert config.db == 2
|
|
assert config.ssl is True
|
|
|
|
@patch.dict(os.environ, {}, clear=True)
|
|
def test_redis_config_from_env_missing(self):
|
|
assert redis_config_from_env() is None
|
|
|
|
|
|
class TestRedisExtractParams:
|
|
def test_extract_params(self):
|
|
sources = {
|
|
"redis": {
|
|
"host": "cache",
|
|
"port": 6380,
|
|
"username": "u",
|
|
"password": "p",
|
|
"db": 1,
|
|
"ssl": True,
|
|
},
|
|
}
|
|
params = redis_extract_params(sources)
|
|
assert params == {
|
|
"host": "cache",
|
|
"port": 6380,
|
|
"username": "u",
|
|
"password": "p",
|
|
"db": 1,
|
|
"ssl": True,
|
|
}
|
|
|
|
def test_extract_params_missing_source(self):
|
|
params = redis_extract_params({})
|
|
assert params["host"] == ""
|
|
assert params["port"] == 6379
|
|
assert params["db"] == 0
|
|
assert params["ssl"] is False
|
|
|
|
|
|
class TestRedisValidation:
|
|
@patch("integrations.redis._get_client")
|
|
def test_validate_success(self, mock_get_client):
|
|
mock_client = MagicMock()
|
|
mock_client.ping.return_value = True
|
|
mock_client.info.return_value = {"redis_version": "7.2.4"}
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = validate_redis_config(RedisConfig(host="cache", port=6379, db=0))
|
|
|
|
assert result.ok is True
|
|
assert "7.2.4" in result.detail
|
|
assert "cache:6379" in result.detail
|
|
mock_client.close.assert_called_once()
|
|
|
|
@patch("integrations.redis._get_client")
|
|
def test_validate_ping_failure(self, mock_get_client):
|
|
mock_client = MagicMock()
|
|
mock_client.ping.return_value = False
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = validate_redis_config(RedisConfig(host="cache"))
|
|
|
|
assert result.ok is False
|
|
assert "unexpected result" in result.detail
|
|
|
|
def test_validate_missing_host(self):
|
|
result = validate_redis_config(RedisConfig(host=""))
|
|
assert result.ok is False
|
|
assert "required" in result.detail
|
|
|
|
@patch("integrations.redis._get_client", side_effect=Exception("Conn error"))
|
|
def test_validate_exception(self, _):
|
|
result = validate_redis_config(RedisConfig(host="cache"))
|
|
assert result.ok is False
|
|
assert "Conn error" in result.detail
|
|
|
|
|
|
class TestRedisServerInfo:
|
|
@patch("integrations.redis._get_client")
|
|
def test_get_server_info(self, mock_get_client):
|
|
mock_client = MagicMock()
|
|
mock_client.info.return_value = {
|
|
"redis_version": "7.2.4",
|
|
"uptime_in_seconds": 1000,
|
|
"used_memory": 1048576,
|
|
"used_memory_human": "1.00M",
|
|
"maxmemory_policy": "allkeys-lru",
|
|
"connected_clients": 12,
|
|
"keyspace_hits": 900,
|
|
"keyspace_misses": 100,
|
|
"evicted_keys": 5,
|
|
"db0": {"keys": 42, "expires": 10, "avg_ttl": 5000},
|
|
}
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = get_server_info(RedisConfig(host="cache"))
|
|
|
|
assert result["available"] is True
|
|
assert result["version"] == "7.2.4"
|
|
assert result["memory"]["used_memory_bytes"] == 1048576
|
|
assert result["clients"]["connected_clients"] == 12
|
|
assert result["stats"]["evicted_keys"] == 5
|
|
assert result["keyspace"]["db0"]["keys"] == 42
|
|
mock_client.close.assert_called_once()
|
|
|
|
def test_get_server_info_not_configured(self):
|
|
result = get_server_info(RedisConfig(host=""))
|
|
assert result["available"] is False
|
|
|
|
|
|
class TestRedisSlowlog:
|
|
@patch("integrations.redis._get_client")
|
|
def test_get_slowlog(self, mock_get_client):
|
|
mock_client = MagicMock()
|
|
mock_client.slowlog_get.return_value = [
|
|
{
|
|
"id": 1,
|
|
"start_time": 1700000000,
|
|
"duration": 12345,
|
|
"command": "GET foo",
|
|
"client_address": "127.0.0.1:5000",
|
|
"client_name": "",
|
|
}
|
|
]
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = get_slowlog(RedisConfig(host="cache"), limit=10)
|
|
|
|
assert result["available"] is True
|
|
assert result["returned_entries"] == 1
|
|
assert result["entries"][0]["duration_microseconds"] == 12345
|
|
assert result["entries"][0]["command"] == "GET foo"
|
|
|
|
@patch("integrations.redis._get_client")
|
|
def test_get_slowlog_decodes_bytes_command(self, mock_get_client):
|
|
mock_client = MagicMock()
|
|
mock_client.slowlog_get.return_value = [
|
|
{"id": 1, "start_time": 1, "duration": 1, "command": b"GET bar"}
|
|
]
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = get_slowlog(RedisConfig(host="cache"))
|
|
assert result["entries"][0]["command"] == "GET bar"
|
|
|
|
|
|
class TestRedisReplication:
|
|
@patch("integrations.redis._get_client")
|
|
def test_master_with_replica_lag(self, mock_get_client):
|
|
mock_client = MagicMock()
|
|
mock_client.info.return_value = {
|
|
"role": "master",
|
|
"connected_slaves": 1,
|
|
"master_repl_offset": 1000,
|
|
"slave0": {"ip": "10.0.0.2", "port": 6379, "state": "online", "offset": 800},
|
|
}
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = get_replication(RedisConfig(host="cache"))
|
|
|
|
assert result["available"] is True
|
|
assert result["role"] == "master"
|
|
assert result["replicas"][0]["lag_bytes"] == 200
|
|
|
|
@patch("integrations.redis._get_client")
|
|
def test_slave_reports_master_link(self, mock_get_client):
|
|
mock_client = MagicMock()
|
|
mock_client.info.return_value = {
|
|
"role": "slave",
|
|
"connected_slaves": 0,
|
|
"master_repl_offset": 0,
|
|
"master_host": "10.0.0.1",
|
|
"master_port": 6379,
|
|
"master_link_status": "up",
|
|
"slave_repl_offset": 950,
|
|
}
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = get_replication(RedisConfig(host="cache"))
|
|
|
|
assert result["role"] == "slave"
|
|
assert result["master"]["link_status"] == "up"
|
|
|
|
|
|
class TestRedisScanKeys:
|
|
@patch("integrations.redis._get_client")
|
|
def test_scan_counts_and_samples(self, mock_get_client):
|
|
mock_client = MagicMock()
|
|
# One SCAN round then cursor 0 to terminate.
|
|
mock_client.scan.return_value = (0, ["session:1", "session:2"])
|
|
mock_client.pipeline.return_value.execute.return_value = [60, "string", -1, "hash"]
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = scan_keys(RedisConfig(host="cache"), pattern="session:*")
|
|
|
|
assert result["available"] is True
|
|
assert result["pattern"] == "session:*"
|
|
assert result["matched_keys"] == 2
|
|
assert result["scan_truncated"] is False
|
|
assert result["samples"][0] == {"key": "session:1", "ttl_seconds": 60, "type": "string"}
|
|
assert result["samples"][1] == {"key": "session:2", "ttl_seconds": -1, "type": "hash"}
|
|
mock_client.pipeline.assert_called_once_with(transaction=False)
|
|
|
|
@patch("integrations.redis._get_client")
|
|
def test_scan_respects_sample_cap_within_a_single_page(self, mock_get_client):
|
|
# A single SCAN page larger than the cap must not oversample: sampling
|
|
# is capped at config.max_results even when one page exceeds it.
|
|
mock_client = MagicMock()
|
|
mock_client.scan.return_value = (0, [f"k{i}" for i in range(60)])
|
|
mock_client.pipeline.return_value.execute.return_value = [1, "string"] * 60
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = scan_keys(RedisConfig(host="cache", max_results=50))
|
|
|
|
assert result["matched_keys"] == 60 # all matches counted
|
|
assert result["sampled_keys"] == 50 # but sampling stays capped
|
|
assert len(result["samples"]) == 50
|
|
|
|
@patch("integrations.redis.report_validation_failure")
|
|
@patch("integrations.redis._get_client")
|
|
def test_scan_auth_error_is_graceful_without_sentry(self, mock_get_client, mock_report):
|
|
import redis.exceptions as redis_exc
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.scan.side_effect = redis_exc.AuthenticationError("WRONGPASS bad pair")
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = scan_keys(RedisConfig(host="cache"))
|
|
assert result["available"] is False
|
|
assert "authentication" in result["error"].lower()
|
|
mock_report.assert_not_called()
|
|
|
|
@patch("integrations.redis.report_validation_failure")
|
|
@patch("integrations.redis._get_client")
|
|
def test_scan_noperm_error_is_graceful_without_sentry(self, mock_get_client, mock_report):
|
|
import redis.exceptions as redis_exc
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.scan.side_effect = redis_exc.NoPermissionError("NOPERM no read access")
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = scan_keys(RedisConfig(host="cache"))
|
|
assert result["available"] is False
|
|
assert "permission" in result["error"].lower()
|
|
mock_report.assert_not_called()
|
|
|
|
@patch("integrations.redis.report_validation_failure")
|
|
@patch("integrations.redis._get_client")
|
|
def test_scan_other_error_reports_sentry(self, mock_get_client, mock_report):
|
|
mock_client = MagicMock()
|
|
mock_client.scan.side_effect = Exception("connection reset")
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = scan_keys(RedisConfig(host="cache"))
|
|
assert result["available"] is False
|
|
assert "connection reset" in result["error"]
|
|
mock_report.assert_called_once()
|
|
|
|
|
|
class TestRedisClientList:
|
|
@patch("integrations.redis._get_client")
|
|
def test_aggregates_blocked_pubsub_and_breakdowns(self, mock_get_client):
|
|
mock_client = MagicMock()
|
|
mock_client.client_list.return_value = [
|
|
{
|
|
"id": "1",
|
|
"addr": "10.0.0.1:5000",
|
|
"flags": "N",
|
|
"idle": "0",
|
|
"cmd": "get",
|
|
"sub": "0",
|
|
},
|
|
{
|
|
"id": "2",
|
|
"addr": "10.0.0.1:5001",
|
|
"flags": "b",
|
|
"idle": "2",
|
|
"cmd": "blpop",
|
|
"sub": "0",
|
|
},
|
|
{
|
|
"id": "3",
|
|
"addr": "10.0.0.2:6000",
|
|
"flags": "P",
|
|
"idle": "120",
|
|
"cmd": "subscribe",
|
|
"sub": "3",
|
|
},
|
|
]
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = get_client_list(RedisConfig(host="cache"))
|
|
|
|
assert result["available"] is True
|
|
assert result["total_clients"] == 3
|
|
assert result["blocked_clients"] == 1 # the "b"-flagged blpop client
|
|
assert result["pubsub_clients"] == 1 # the "P"-flagged subscriber
|
|
assert result["max_idle_seconds"] == 120
|
|
assert result["address_breakdown"]["10.0.0.1"] == 2
|
|
assert result["command_breakdown"]["blpop"] == 1
|
|
assert result["returned_clients"] == 3
|
|
assert result["clients"][1]["blocked"] is True
|
|
# CLIENT LIST returns every field as a string (parse_client_list); the
|
|
# sample must coerce the numeric fields to int via safe_int.
|
|
first = result["clients"][0]
|
|
assert first["id"] == 1 and isinstance(first["id"], int)
|
|
assert isinstance(first["db"], int)
|
|
assert result["clients"][2]["idle_seconds"] == 120
|
|
assert isinstance(result["clients"][2]["idle_seconds"], int)
|
|
mock_client.close.assert_called_once()
|
|
|
|
@patch("integrations.redis._get_client")
|
|
def test_sample_capped_but_aggregates_count_all(self, mock_get_client):
|
|
mock_client = MagicMock()
|
|
mock_client.client_list.return_value = [
|
|
{"id": str(i), "addr": f"10.0.0.{i}:5000", "flags": "N", "idle": "0", "cmd": "ping"}
|
|
for i in range(60)
|
|
]
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = get_client_list(RedisConfig(host="cache", max_results=50))
|
|
|
|
assert result["total_clients"] == 60 # all counted
|
|
assert result["returned_clients"] == 50 # sample capped
|
|
assert len(result["address_breakdown"]) == 50 # top-N cap on breakdown
|
|
|
|
@patch("integrations.redis._get_client")
|
|
def test_pubsub_detected_by_flag_or_subscription_independently(self, mock_get_client):
|
|
# is_pubsub = "P" in flags OR (sub + psub) > 0. Each disjunct must count a
|
|
# client on its own — otherwise a regression in either half hides behind the
|
|
# other. One client per branch, plus a plain client that must NOT count.
|
|
mock_client = MagicMock()
|
|
mock_client.client_list.return_value = [
|
|
{
|
|
"id": "1",
|
|
"addr": "10.0.0.1:5000",
|
|
"flags": "N",
|
|
"cmd": "get",
|
|
"sub": "0",
|
|
"psub": "0",
|
|
},
|
|
# flag-only: pub/sub via the "P" flag, zero subscriptions
|
|
{
|
|
"id": "2",
|
|
"addr": "10.0.0.1:5001",
|
|
"flags": "P",
|
|
"cmd": "subscribe",
|
|
"sub": "0",
|
|
"psub": "0",
|
|
},
|
|
# sub-only: no "P" flag, channel subscriptions > 0
|
|
{
|
|
"id": "3",
|
|
"addr": "10.0.0.1:5002",
|
|
"flags": "N",
|
|
"cmd": "subscribe",
|
|
"sub": "2",
|
|
"psub": "0",
|
|
},
|
|
# psub-only: no "P" flag, pattern subscriptions > 0 (exercises the psub coercion)
|
|
{
|
|
"id": "4",
|
|
"addr": "10.0.0.1:5003",
|
|
"flags": "N",
|
|
"cmd": "psubscribe",
|
|
"sub": "0",
|
|
"psub": "2",
|
|
},
|
|
]
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = get_client_list(RedisConfig(host="cache"))
|
|
|
|
assert result["pubsub_clients"] == 3 # ids 2, 3, 4 — not the plain client id 1
|
|
by_id = {c["id"]: c["pubsub"] for c in result["clients"]}
|
|
assert by_id == {1: False, 2: True, 3: True, 4: True}
|
|
|
|
def test_not_configured(self):
|
|
assert get_client_list(RedisConfig(host=""))["available"] is False
|
|
|
|
|
|
class TestRedisListDepth:
|
|
@patch("integrations.redis._get_client")
|
|
def test_list_depth_with_head_and_tail(self, mock_get_client):
|
|
mock_client = MagicMock()
|
|
mock_client.type.return_value = "list"
|
|
mock_client.pipeline.return_value.execute.return_value = [3, ["a", "b"], ["c"]]
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = get_list_depth(RedisConfig(host="cache"), key="jobs", head=2, tail=1)
|
|
|
|
assert result["available"] is True
|
|
assert result["type"] == "list"
|
|
assert result["exists"] is True
|
|
assert result["depth"] == 3
|
|
assert result["head"] == ["a", "b"]
|
|
assert result["tail"] == ["c"]
|
|
mock_client.pipeline.assert_called_once_with(transaction=False)
|
|
mock_client.close.assert_called_once()
|
|
# Head uses [0, n-1]; tail uses negative indices [-n, -1] — different slices.
|
|
lrange_calls = mock_client.pipeline.return_value.lrange.call_args_list
|
|
assert lrange_calls[0].args == ("jobs", 0, 1) # head
|
|
assert lrange_calls[1].args == ("jobs", -1, -1) # tail
|
|
|
|
@patch("integrations.redis._get_client")
|
|
def test_depth_only_when_no_sample_requested(self, mock_get_client):
|
|
mock_client = MagicMock()
|
|
mock_client.type.return_value = "list"
|
|
mock_client.pipeline.return_value.execute.return_value = [42]
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = get_list_depth(RedisConfig(host="cache"), key="jobs")
|
|
|
|
assert result["depth"] == 42
|
|
assert result["head"] == []
|
|
assert result["tail"] == []
|
|
|
|
@patch("integrations.redis._get_client")
|
|
def test_missing_key_reports_not_exists(self, mock_get_client):
|
|
mock_client = MagicMock()
|
|
mock_client.type.return_value = "none"
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = get_list_depth(RedisConfig(host="cache"), key="absent")
|
|
|
|
assert result["available"] is True
|
|
assert result["exists"] is False
|
|
assert result["depth"] == 0
|
|
mock_client.pipeline.assert_not_called() # no LLEN/LRANGE on a missing key
|
|
|
|
@patch("integrations.redis._get_client")
|
|
def test_wrong_type_returns_clear_message_not_wrongtype_error(self, mock_get_client):
|
|
mock_client = MagicMock()
|
|
mock_client.type.return_value = "string"
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = get_list_depth(RedisConfig(host="cache"), key="counter")
|
|
|
|
assert result["available"] is True
|
|
assert result["type"] == "string"
|
|
assert result["depth"] is None
|
|
assert "not a list" in result["error"]
|
|
mock_client.pipeline.assert_not_called()
|
|
|
|
@patch("integrations.redis._get_client")
|
|
def test_head_sample_capped_and_values_truncated(self, mock_get_client):
|
|
long_value = "x" * 1000
|
|
mock_client = MagicMock()
|
|
mock_client.type.return_value = "list"
|
|
mock_client.pipeline.return_value.execute.return_value = [1, [long_value]]
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = get_list_depth(RedisConfig(host="cache"), key="jobs", head=999)
|
|
|
|
# head clamped to max_results (default 50) when the LRANGE was issued
|
|
_, start, end = mock_client.pipeline.return_value.lrange.call_args.args
|
|
assert (start, end) == (0, 49)
|
|
# each value truncated to the preview cap
|
|
assert result["head"][0].endswith("…")
|
|
assert len(result["head"][0]) <= 257
|
|
|
|
@patch("integrations.redis._get_client")
|
|
def test_tail_only_sampling(self, mock_get_client):
|
|
# head=0, tail>0: the pipeline buffers only [llen, lrange(tail)], so the
|
|
# cursor must read the tail from results[1] — the asymmetric branch.
|
|
mock_client = MagicMock()
|
|
mock_client.type.return_value = "list"
|
|
mock_client.pipeline.return_value.execute.return_value = [10, ["last-job"]]
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = get_list_depth(RedisConfig(host="cache"), key="jobs", tail=1)
|
|
|
|
assert result["depth"] == 10
|
|
assert result["head"] == []
|
|
assert result["tail"] == ["last-job"]
|
|
assert mock_client.pipeline.return_value.lrange.call_args.args == ("jobs", -1, -1)
|
|
|
|
@patch("integrations.redis._get_client")
|
|
def test_tail_sample_clamped_to_max_results(self, mock_get_client):
|
|
mock_client = MagicMock()
|
|
mock_client.type.return_value = "list"
|
|
mock_client.pipeline.return_value.execute.return_value = [1000, ["x"]]
|
|
mock_get_client.return_value = mock_client
|
|
|
|
get_list_depth(RedisConfig(host="cache", max_results=50), key="jobs", tail=999)
|
|
|
|
# tail clamped to max_results -> LRANGE(-50, -1)
|
|
assert mock_client.pipeline.return_value.lrange.call_args.args == ("jobs", -50, -1)
|
|
|
|
@patch("integrations.redis._get_client")
|
|
def test_negative_head_tail_collapse_to_zero_no_lrange(self, mock_get_client):
|
|
# head_n/tail_n = max(0, min(head or 0, max_results)). A negative bound is
|
|
# truthy, so without the max(0, ...) floor it would forward a negative count
|
|
# into LRANGE; assert it collapses to 0 and no LRANGE is buffered.
|
|
mock_client = MagicMock()
|
|
mock_client.type.return_value = "list"
|
|
mock_client.pipeline.return_value.execute.return_value = [42] # LLEN only
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = get_list_depth(RedisConfig(host="cache"), key="jobs", head=-5, tail=-5)
|
|
|
|
assert result["depth"] == 42
|
|
assert result["head"] == []
|
|
assert result["tail"] == []
|
|
mock_client.pipeline.return_value.lrange.assert_not_called()
|
|
|
|
def test_empty_key_rejected(self):
|
|
result = get_list_depth(RedisConfig(host="cache"), key=" ")
|
|
assert result["available"] is False
|
|
assert "required" in result["error"]
|
|
|
|
|
|
class TestRedisLatencyDoctor:
|
|
@staticmethod
|
|
def _mock_client(threshold: str = "100", latest=None, history=None, report="report"):
|
|
mock_client = MagicMock()
|
|
mock_client.execute_command.return_value = report
|
|
mock_client.latency_latest.return_value = latest if latest is not None else []
|
|
mock_client.latency_history.return_value = history if history is not None else []
|
|
mock_client.config_get.return_value = {"latency-monitor-threshold": threshold}
|
|
return mock_client
|
|
|
|
@patch("integrations.redis._get_client")
|
|
def test_report_latest_and_history(self, mock_get_client):
|
|
mock_get_client.return_value = self._mock_client(
|
|
threshold="100",
|
|
report="Dave, I found spikes in command.",
|
|
latest=[["command", 1700000000, 250, 900]],
|
|
history=[[1700000000, 250], [1700000060, 300]],
|
|
)
|
|
|
|
result = get_latency_doctor(RedisConfig(host="cache"), event="command")
|
|
|
|
assert result["available"] is True
|
|
assert result["monitoring_active"] is True
|
|
assert result["monitoring_threshold_ms"] == 100
|
|
assert result["monitored_events"] == 1
|
|
assert result["latest"][0]["event"] == "command"
|
|
assert result["latest"][0]["max_ms"] == 900
|
|
assert result["history"] == [
|
|
{"timestamp": 1700000000, "latency_ms": 250},
|
|
{"timestamp": 1700000060, "latency_ms": 300},
|
|
]
|
|
mock_get_client.return_value.execute_command.assert_called_once_with("LATENCY", "DOCTOR")
|
|
mock_get_client.return_value.close.assert_called_once()
|
|
|
|
@patch("integrations.redis._get_client")
|
|
def test_monitoring_disabled_reports_inactive(self, mock_get_client):
|
|
mock_get_client.return_value = self._mock_client(threshold="0", latest=[])
|
|
|
|
result = get_latency_doctor(RedisConfig(host="cache"))
|
|
|
|
assert result["available"] is True
|
|
assert result["monitoring_active"] is False
|
|
assert result["monitoring_threshold_ms"] == 0
|
|
assert result["latest"] == []
|
|
mock_get_client.return_value.latency_history.assert_not_called() # no event requested
|
|
|
|
@patch("integrations.redis._get_client")
|
|
def test_enabled_but_quiet_reports_active(self, mock_get_client):
|
|
# The key regression: monitoring is ON (threshold > 0) but no spike has
|
|
# crossed it yet — a healthy server, NOT "monitoring disabled".
|
|
mock_get_client.return_value = self._mock_client(threshold="100", latest=[])
|
|
|
|
result = get_latency_doctor(RedisConfig(host="cache"))
|
|
|
|
assert result["monitoring_active"] is True
|
|
assert result["monitoring_threshold_ms"] == 100
|
|
assert result["latest"] == []
|
|
|
|
@patch("integrations.redis._get_client")
|
|
def test_config_get_denied_falls_back_to_event_presence(self, mock_get_client):
|
|
import redis.exceptions as redis_exc
|
|
|
|
mock_client = self._mock_client(latest=[["command", 1, 2, 3]])
|
|
mock_client.config_get.side_effect = redis_exc.NoPermissionError("NOPERM config")
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = get_latency_doctor(RedisConfig(host="cache"))
|
|
|
|
# CONFIG denied -> threshold unknown, but events exist so monitoring is on.
|
|
assert result["monitoring_threshold_ms"] is None
|
|
assert result["monitoring_active"] is True
|
|
|
|
@patch("integrations.redis._get_client")
|
|
def test_config_get_denied_and_no_events_reports_inactive(self, mock_get_client):
|
|
# The other half of the fallback: CONFIG denied AND no monitored events ->
|
|
# bool([]) is False, so monitoring_active must be False. Without this case a
|
|
# regression to `else True` would still pass the suite.
|
|
import redis.exceptions as redis_exc
|
|
|
|
mock_client = self._mock_client(latest=[])
|
|
mock_client.config_get.side_effect = redis_exc.NoPermissionError("NOPERM config")
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = get_latency_doctor(RedisConfig(host="cache"))
|
|
|
|
assert result["monitoring_threshold_ms"] is None
|
|
assert result["monitoring_active"] is False
|
|
|
|
@patch("integrations.redis._get_client")
|
|
def test_history_capped_by_max_results_and_explicit_limit(self, mock_get_client):
|
|
mock_get_client.return_value = self._mock_client(history=[[i, i] for i in range(100)])
|
|
cfg = RedisConfig(host="cache", max_results=50)
|
|
|
|
# default: capped at max_results
|
|
assert len(get_latency_doctor(cfg, event="command")["history"]) == 50
|
|
# explicit smaller limit honored
|
|
assert len(get_latency_doctor(cfg, event="command", history_limit=5)["history"]) == 5
|
|
# explicit larger limit clamped down to max_results
|
|
assert len(get_latency_doctor(cfg, event="command", history_limit=500)["history"]) == 50
|
|
|
|
@patch("integrations.redis._get_client")
|
|
def test_negative_history_limit_yields_empty_not_back_truncated(self, mock_get_client):
|
|
# A negative history_limit is truthy and survives the min(); the max(0, ...)
|
|
# floor must turn it into an empty (count=0) slice rather than
|
|
# history_raw[:-n], which would silently drop the most recent events.
|
|
mock_get_client.return_value = self._mock_client(history=[[i, i] for i in range(100)])
|
|
cfg = RedisConfig(host="cache", max_results=50)
|
|
|
|
result = get_latency_doctor(cfg, event="command", history_limit=-5)
|
|
|
|
assert result["history"] == []
|
|
|
|
|
|
class TestNewToolErrorHandling:
|
|
@patch("integrations.redis.report_validation_failure")
|
|
@patch("integrations.redis._get_client")
|
|
def test_client_list_auth_error_is_graceful_without_sentry(self, mock_get_client, mock_report):
|
|
import redis.exceptions as redis_exc
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.client_list.side_effect = redis_exc.AuthenticationError("WRONGPASS")
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = get_client_list(RedisConfig(host="cache"))
|
|
assert result["available"] is False
|
|
assert "authentication" in result["error"].lower()
|
|
mock_report.assert_not_called()
|
|
|
|
@patch("integrations.redis.report_validation_failure")
|
|
@patch("integrations.redis._get_client")
|
|
def test_latency_other_error_reports_sentry(self, mock_get_client, mock_report):
|
|
mock_client = MagicMock()
|
|
mock_client.execute_command.side_effect = Exception("connection reset")
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = get_latency_doctor(RedisConfig(host="cache"))
|
|
assert result["available"] is False
|
|
assert "connection reset" in result["error"]
|
|
mock_report.assert_called_once()
|
|
|
|
|
|
class TestResolveIntegrations:
|
|
def test_classify_redis(self):
|
|
integrations = [
|
|
{
|
|
"id": "123",
|
|
"service": "redis",
|
|
"status": "active",
|
|
"credentials": {
|
|
"host": "cache.example.net",
|
|
"port": 6380,
|
|
"password": "secret",
|
|
"db": 1,
|
|
},
|
|
}
|
|
]
|
|
resolved = _classify_integrations(integrations)
|
|
assert "redis" in resolved
|
|
assert resolved["redis"].host == "cache.example.net"
|
|
assert resolved["redis"].port == 6380
|
|
assert resolved["redis"].db == 1
|
|
assert resolved["redis"].ssl is False # default
|
|
assert resolved["redis"].integration_id == "123"
|