4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
340 lines
13 KiB
Python
340 lines
13 KiB
Python
"""Redis E2E tests verifying integration with investigation pipeline.
|
|
|
|
Tests:
|
|
- Redis config resolution from store and env
|
|
- Redis verification (ping, server info)
|
|
- Redis source detection in investigation state
|
|
- Redis tools availability for query execution
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from integrations.catalog import classify_integrations as _classify_integrations
|
|
from integrations.verify import verify_integrations
|
|
from tests.e2e.source_helpers import resolve_available_tool_sources
|
|
|
|
|
|
class TestRedisIntegrationResolution:
|
|
"""Test Redis config resolution from multiple sources."""
|
|
|
|
def test_redis_resolution_from_store(self):
|
|
"""Redis integration correctly resolved from local store."""
|
|
integrations = [
|
|
{
|
|
"id": "redis-prod",
|
|
"service": "redis",
|
|
"status": "active",
|
|
"credentials": {
|
|
"host": "prod-cache.redis.internal",
|
|
"port": 6380,
|
|
"username": "monitor",
|
|
"password": "s3cret",
|
|
"db": 1,
|
|
"ssl": True,
|
|
},
|
|
}
|
|
]
|
|
resolved = _classify_integrations(integrations)
|
|
|
|
assert "redis" in resolved
|
|
assert resolved["redis"]["host"] == "prod-cache.redis.internal"
|
|
assert resolved["redis"]["port"] == 6380
|
|
assert resolved["redis"]["username"] == "monitor"
|
|
assert resolved["redis"]["db"] == 1
|
|
assert resolved["redis"]["ssl"] is True
|
|
|
|
def test_redis_invalid_config_skipped(self):
|
|
"""Invalid Redis integration config is safely skipped."""
|
|
integrations = [
|
|
{
|
|
"id": "bad-redis",
|
|
"service": "redis",
|
|
"status": "active",
|
|
"credentials": {
|
|
"host": "",
|
|
},
|
|
}
|
|
]
|
|
resolved = _classify_integrations(integrations)
|
|
|
|
# Should not include Redis if host is empty
|
|
assert resolved.get("redis") is None
|
|
|
|
|
|
class TestRedisToolSourceAvailability:
|
|
"""Test Redis source availability in the tool-registry investigation path."""
|
|
|
|
def test_redis_tool_source_available_from_resolved_integration(self):
|
|
"""Redis source is available when a configured integration exists."""
|
|
resolved_integrations = {
|
|
"redis": {
|
|
"host": "localhost",
|
|
"port": 6379,
|
|
"username": "",
|
|
"password": "",
|
|
"db": 0,
|
|
"ssl": False,
|
|
}
|
|
}
|
|
|
|
sources = resolve_available_tool_sources(resolved_integrations)
|
|
|
|
assert "redis" in sources
|
|
assert sources["redis"]["host"] == "localhost"
|
|
assert sources["redis"]["port"] == 6379
|
|
|
|
def test_redis_tool_source_uses_configured_db(self):
|
|
"""Redis tool params come from the resolved integration config."""
|
|
resolved_integrations = {
|
|
"redis": {
|
|
"host": "localhost",
|
|
"port": 6379,
|
|
"username": "",
|
|
"password": "",
|
|
"db": 3,
|
|
"ssl": False,
|
|
}
|
|
}
|
|
|
|
sources = resolve_available_tool_sources(resolved_integrations)
|
|
|
|
assert "redis" in sources
|
|
assert sources["redis"]["db"] == 3
|
|
|
|
def test_redis_tool_source_unavailable_if_unconfigured(self):
|
|
"""Redis source is not included if not configured."""
|
|
resolved_integrations = {}
|
|
|
|
sources = resolve_available_tool_sources(resolved_integrations)
|
|
|
|
assert "redis" not in sources
|
|
|
|
|
|
class TestRedisVerification:
|
|
"""Test Redis integration verification flow."""
|
|
|
|
@patch("integrations.redis._get_client")
|
|
def test_verify_redis_success(self, mock_get_client):
|
|
"""Redis verification succeeds with valid config."""
|
|
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
|
|
|
|
results = verify_integrations(service="redis")
|
|
|
|
assert len(results) >= 1
|
|
redis_result = next((r for r in results if r["service"] == "redis"), None)
|
|
assert redis_result is not None
|
|
# Status can be passed or missing depending on env config
|
|
assert redis_result["status"] in ("passed", "missing")
|
|
|
|
def test_verify_integrations_structure(self):
|
|
"""Verify integrations returns expected result structure."""
|
|
# Just verify the function exists and can be called - actual verification
|
|
# depends on environment setup (Redis connection available)
|
|
try:
|
|
results = verify_integrations(service="redis")
|
|
assert isinstance(results, list)
|
|
for result in results:
|
|
if result["service"] == "redis":
|
|
assert "status" in result
|
|
assert "detail" in result
|
|
assert result["status"] in ("passed", "missing", "failed")
|
|
except Exception as exc:
|
|
# If no Redis is configured, that's ok - just testing structure
|
|
assert exc.__class__.__name__
|
|
|
|
|
|
class TestRedisToolsAvailability:
|
|
"""Test Redis tools are available and configured."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_registry_cache(self):
|
|
"""Force fresh tool discovery so registry assertions never depend on a
|
|
cache populated by a prior test (per the ``tests/`` convention that any
|
|
test calling ``get_registered_tools()`` clears the cache in a fixture)."""
|
|
from tools.registry import clear_tool_registry_cache
|
|
|
|
clear_tool_registry_cache()
|
|
yield
|
|
clear_tool_registry_cache()
|
|
|
|
def test_redis_tools_exist_as_modules(self):
|
|
"""Redis tools modules exist and are properly structured."""
|
|
try:
|
|
import importlib
|
|
|
|
redis_client_list_tool = importlib.import_module(
|
|
"integrations.redis.tools.redis_client_list_tool"
|
|
)
|
|
redis_key_scan_tool = importlib.import_module(
|
|
"integrations.redis.tools.redis_key_scan_tool"
|
|
)
|
|
redis_latency_doctor_tool = importlib.import_module(
|
|
"integrations.redis.tools.redis_latency_doctor_tool"
|
|
)
|
|
redis_list_depth_tool = importlib.import_module(
|
|
"integrations.redis.tools.redis_list_depth_tool"
|
|
)
|
|
redis_replication_tool = importlib.import_module(
|
|
"integrations.redis.tools.redis_replication_tool"
|
|
)
|
|
redis_server_info_tool = importlib.import_module(
|
|
"integrations.redis.tools.redis_server_info_tool"
|
|
)
|
|
redis_slowlog_tool = importlib.import_module(
|
|
"integrations.redis.tools.redis_slowlog_tool"
|
|
)
|
|
|
|
# All 7 tool modules should be importable (4 baseline + 3 P1)
|
|
assert redis_server_info_tool is not None
|
|
assert redis_slowlog_tool is not None
|
|
assert redis_replication_tool is not None
|
|
assert redis_key_scan_tool is not None
|
|
assert redis_client_list_tool is not None
|
|
assert redis_list_depth_tool is not None
|
|
assert redis_latency_doctor_tool is not None
|
|
except ImportError as e:
|
|
pytest.fail(f"Failed to import Redis tool modules: {e}")
|
|
|
|
def test_p1_tools_registered_on_investigation_surface(self):
|
|
"""The three new P1 tools are discoverable on the investigation and chat surfaces."""
|
|
from tools.registry import get_registered_tools
|
|
|
|
p1_tools = {
|
|
"get_redis_client_list",
|
|
"get_redis_list_depth",
|
|
"get_redis_latency_doctor",
|
|
}
|
|
for surface in ("investigation", "chat"):
|
|
names = {t.name for t in get_registered_tools(surface) if t.source == "redis"}
|
|
assert p1_tools <= names, (
|
|
f"missing P1 redis tools on {surface} surface: {p1_tools - names}"
|
|
)
|
|
|
|
def test_redis_integration_config_has_required_fields(self):
|
|
"""Redis integration provides required fields in resolved config."""
|
|
from integrations.config_models import RedisIntegrationConfig
|
|
|
|
config = RedisIntegrationConfig(
|
|
host="localhost",
|
|
port=6379,
|
|
username="monitor",
|
|
password="s3cret",
|
|
db=0,
|
|
ssl=True,
|
|
integration_id="test-id",
|
|
)
|
|
|
|
assert config.host == "localhost"
|
|
assert config.port == 6379
|
|
assert config.username == "monitor"
|
|
assert config.db == 0
|
|
assert config.ssl is True
|
|
assert config.integration_id == "test-id"
|
|
|
|
|
|
class TestRedisP1ToolPaths:
|
|
"""Exercise each new P1 tool end-to-end: tool fn -> helper -> client -> shape.
|
|
|
|
The Redis client is mocked at the transport boundary so the full tool path
|
|
(config build, command issue, response shaping, bounded sampling) is
|
|
covered without a live Redis, mirroring how a real investigation would call
|
|
these tools with credentials resolved from the integration config.
|
|
"""
|
|
|
|
@patch("integrations.redis._get_client")
|
|
def test_client_list_tool_path(self, mock_get_client):
|
|
from integrations.redis.tools.redis_client_list_tool import get_redis_client_list
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.client_list.return_value = [
|
|
{"id": "1", "addr": "10.0.0.1:5000", "flags": "N", "idle": "0", "cmd": "get"},
|
|
{"id": "2", "addr": "10.0.0.1:5001", "flags": "b", "idle": "5", "cmd": "blpop"},
|
|
]
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = get_redis_client_list(host="prod-cache.redis.internal", port=6379, db=0)
|
|
|
|
assert result["available"] is True
|
|
assert result["total_clients"] == 2
|
|
assert result["blocked_clients"] == 1
|
|
assert result["address_breakdown"]["10.0.0.1"] == 2
|
|
|
|
@patch("integrations.redis._get_client")
|
|
def test_list_depth_tool_path(self, mock_get_client):
|
|
from integrations.redis.tools.redis_list_depth_tool import get_redis_list_depth
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.type.return_value = "list"
|
|
mock_client.pipeline.return_value.execute.return_value = [1500, ["job-1"], ["job-1500"]]
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = get_redis_list_depth(
|
|
key="sidekiq:queue:default",
|
|
host="prod-cache.redis.internal",
|
|
head=1,
|
|
tail=1,
|
|
)
|
|
|
|
assert result["available"] is True
|
|
assert result["depth"] == 1500 # a real backlog
|
|
assert result["head"] == ["job-1"]
|
|
assert result["tail"] == ["job-1500"]
|
|
|
|
@patch("integrations.redis._get_client")
|
|
def test_latency_doctor_tool_path(self, mock_get_client):
|
|
from integrations.redis.tools.redis_latency_doctor_tool import get_redis_latency_doctor
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.execute_command.return_value = "I detected spikes caused by fork."
|
|
mock_client.latency_latest.return_value = [["fork", 1700000000, 480, 1200]]
|
|
# Explicitly configure CONFIG GET so monitoring_active is driven by the
|
|
# real threshold (> 0) — not MagicMock's implicit __int__ == 1, which
|
|
# would make the assertion pass even if the threshold logic regressed.
|
|
mock_client.config_get.return_value = {"latency-monitor-threshold": "100"}
|
|
mock_get_client.return_value = mock_client
|
|
|
|
result = get_redis_latency_doctor(host="prod-cache.redis.internal")
|
|
|
|
assert result["available"] is True
|
|
assert result["monitoring_active"] is True
|
|
assert result["monitoring_threshold_ms"] == 100
|
|
assert result["latest"][0]["event"] == "fork"
|
|
assert "fork" in result["report"]
|
|
|
|
|
|
class TestRedisAlertFixture:
|
|
"""Test the Redis alert fixture is valid and parseable."""
|
|
|
|
def test_redis_alert_fixture_is_valid_json(self):
|
|
"""Redis alert fixture is valid JSON."""
|
|
fixture_path = Path(__file__).parent / "redis_alert.json"
|
|
assert fixture_path.exists(), f"Alert fixture not found at {fixture_path}"
|
|
|
|
with fixture_path.open() as f:
|
|
alert = json.load(f)
|
|
|
|
assert isinstance(alert, dict)
|
|
assert "state" in alert
|
|
assert "commonLabels" in alert
|
|
assert "commonAnnotations" in alert
|
|
|
|
def test_redis_alert_fixture_has_redis_context(self):
|
|
"""Redis alert fixture contains Redis-specific context."""
|
|
fixture_path = Path(__file__).parent / "redis_alert.json"
|
|
|
|
with fixture_path.open() as f:
|
|
alert = json.load(f)
|
|
|
|
labels = alert.get("commonLabels", {})
|
|
# Alert should have Redis-specific fields for source detection
|
|
assert "redis_instance" in labels
|