Skip to content

Commit 6b8bd1d

Browse files
committed
Add missing CLI command tests
1 parent 739137d commit 6b8bd1d

File tree

18 files changed

+1087
-75
lines changed

18 files changed

+1087
-75
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,4 @@ ui/test-results/
137137
# SSL certificates (generated locally)
138138
monitoring/nginx/certs/
139139
config.yaml
140+
eval_reports

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ dependencies = [
5858
"opentelemetry-instrumentation-aiohttp-client>=0.57b0",
5959
"opentelemetry-instrumentation-openai>=0.47.5",
6060
"mcp>=1.23.3",
61+
"nltk>=3.9.1",
6162
]
6263

6364
[dependency-groups]

redis_sre_agent/cli/knowledge.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ def knowledge():
2525

2626
@knowledge.command("search")
2727
@click.argument("query", nargs=-1)
28-
@click.option("--limit", "-l", default=5, help="Number of results to return")
2928
@click.option("--category", "-c", type=str, help="Filter by category")
29+
@click.option("--limit", "-l", default=10, help="Number of results to return")
30+
@click.option("--offset", "-o", default=0, help="Offset for pagination")
3031
@click.option("--distance-threshold", "-d", type=float, help="Cosine distance threshold")
3132
@click.option(
3233
"--hybrid-search",
@@ -35,11 +36,14 @@ def knowledge():
3536
default=False,
3637
help="Use hybrid search (vector + full-text)",
3738
)
39+
@click.option("--version", "-v", type=str, default="latest", help="Redis version filter")
3840
def knowledge_search(
39-
limit: int,
4041
category: Optional[str],
42+
limit: int,
43+
offset: int,
4144
distance_threshold: Optional[float],
42-
hybrid_search: bool = False,
45+
hybrid_search: bool,
46+
version: Optional[str],
4347
query: str = "*",
4448
):
4549
"""Search the knowledge base (query helpers group)."""
@@ -48,6 +52,7 @@ async def _run():
4852
kwargs = {
4953
"query": " ".join(query),
5054
"limit": limit,
55+
"offset": offset,
5156
"distance_threshold": distance_threshold,
5257
"hybrid_search": hybrid_search,
5358
}
@@ -58,6 +63,9 @@ async def _run():
5863
click.echo(f"📂 Category filter: {category}")
5964
if distance_threshold:
6065
click.echo(f"📏 Distance threshold: {distance_threshold}")
66+
if version:
67+
kwargs["version"] = version
68+
click.echo(f"🔢 Version filter: {version}")
6169
click.echo(f"🔢 Limit: {limit}")
6270

6371
result = await search_knowledge_base_helper(**kwargs)
@@ -73,6 +81,7 @@ async def _run():
7381
click.echo(f"Title: {doc.get('title', 'Unknown')}")
7482
click.echo(f"Source: {doc.get('source', 'Unknown')}")
7583
click.echo(f"Category: {doc.get('category', 'general')}")
84+
click.echo(f"Version: {doc.get('version', 'None')}")
7685
content = doc.get("content", "")
7786
if len(content) > 1000:
7887
content = content[:1000] + "..."

redis_sre_agent/cli/worker.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,19 @@ async def _worker():
8686
except Exception as _e:
8787
logger.warning(f"Failed to start Prometheus metrics server in worker: {_e}")
8888

89+
# Initialize Redis infrastructure (creates indices if they don't exist)
90+
try:
91+
from redis_sre_agent.core.redis import create_indices
92+
93+
indices_created = await create_indices()
94+
if indices_created:
95+
logger.info("✅ Redis indices initialized")
96+
else:
97+
logger.warning("⚠️ Failed to create some Redis indices")
98+
except Exception as e:
99+
logger.error(f"Failed to initialize Redis indices: {e}")
100+
# Continue anyway - some functionality may still work
101+
89102
try:
90103
# Register tasks first (support both sync and async implementations)
91104
reg = register_sre_tasks()

redis_sre_agent/core/knowledge_helpers.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,8 @@ async def search_knowledge_base_helper(
104104
text=query,
105105
num_results=fetch_limit,
106106
return_fields=return_fields,
107+
filter_expression=filter_expr,
107108
)
108-
if filter_expr is not None:
109-
query_obj.set_filter(filter_expr)
110109
else:
111110
# Build pure vector query
112111
# distance_threshold default is 0.5; None disables threshold (pure KNN)

tests/conftest.py

Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33
"""
44

55
import os
6-
import subprocess
7-
import time
86
from typing import Any, Dict, List
97
from unittest.mock import AsyncMock, Mock, patch
108

@@ -67,34 +65,9 @@ def pytest_configure(config):
6765
os.environ["OPENAI_INTEGRATION_TESTS"] = "true"
6866
os.environ["AGENT_BEHAVIOR_TESTS"] = "true"
6967
os.environ["INTEGRATION_TESTS"] = "true" # Needed for redis_container fixture
70-
71-
# If running full suite and INTEGRATION_TESTS requested, ensure docker compose is up
72-
if os.environ.get("INTEGRATION_TESTS") and not os.environ.get("CI"):
73-
try:
74-
# Start only infra services to avoid building app images during tests
75-
subprocess.run(
76-
[
77-
"docker",
78-
"compose",
79-
"-f",
80-
"docker-compose.yml",
81-
"-f",
82-
"docker-compose.test.yml",
83-
"up",
84-
"-d",
85-
"redis",
86-
"redis-exporter",
87-
"prometheus",
88-
"node-exporter",
89-
"grafana",
90-
],
91-
check=False,
92-
)
93-
# Give services a moment to start
94-
time.sleep(3)
95-
except Exception:
96-
# Non-fatal; testcontainers fallback will still work
97-
pass
68+
# Note: We intentionally do NOT start docker-compose here.
69+
# Integration tests use testcontainers via the redis_container fixture,
70+
# which manages Redis lifecycle automatically with docker-compose.integration.yml.
9871

9972

10073
def pytest_collection_modifyitems(config, items):

tests/integration/tools/diagnostics/redis_command/test_redis_cli_integration.py

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,6 @@
11
"""Integration tests for Redis Command Diagnostics provider with ToolManager."""
22

33
import pytest
4-
from testcontainers.redis import RedisContainer
5-
6-
7-
@pytest.fixture(scope="module")
8-
def redis_container():
9-
"""Start a Redis container for testing."""
10-
with RedisContainer("redis:8.2.1") as redis:
11-
yield redis
12-
13-
14-
@pytest.fixture
15-
def redis_url(redis_container):
16-
"""Get Redis connection URL from container."""
17-
host = redis_container.get_container_host_ip()
18-
port = redis_container.get_exposed_port(6379)
19-
return f"redis://{host}:{port}"
204

215

226
@pytest.mark.asyncio

tests/integration/tools/diagnostics/redis_command/test_redis_command_provider.py

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,13 @@
33
from unittest.mock import AsyncMock, patch
44

55
import pytest
6-
from testcontainers.redis import RedisContainer
76

87
from redis_sre_agent.tools.diagnostics.redis_command import (
98
RedisCommandToolProvider,
109
)
1110
from redis_sre_agent.tools.protocols import ToolCapability
1211

1312

14-
@pytest.fixture(scope="module")
15-
def redis_container():
16-
"""Start a Redis container for testing."""
17-
with RedisContainer("redis:8.2.1") as redis:
18-
yield redis
19-
20-
21-
@pytest.fixture
22-
def redis_url(redis_container):
23-
"""Get Redis connection URL from container."""
24-
host = redis_container.get_container_host_ip()
25-
port = redis_container.get_exposed_port(6379)
26-
return f"redis://{host}:{port}"
27-
28-
2913
@pytest.mark.asyncio
3014
async def test_provider_initialization(redis_url):
3115
"""Test that provider initializes correctly."""

tests/unit/cli/test_cli_index.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""Tests for the `index` CLI commands."""
2+
3+
from unittest.mock import AsyncMock, MagicMock, patch
4+
5+
import pytest
6+
from click.testing import CliRunner
7+
8+
from redis_sre_agent.cli.index import index
9+
10+
11+
@pytest.fixture
12+
def cli_runner():
13+
"""Click CLI test runner."""
14+
return CliRunner()
15+
16+
17+
class TestIndexListCLI:
18+
"""Test index list CLI command."""
19+
20+
def test_list_help_shows_options(self, cli_runner):
21+
"""Test that list command shows expected options in help."""
22+
result = cli_runner.invoke(index, ["list", "--help"])
23+
24+
assert result.exit_code == 0
25+
assert "--json" in result.output
26+
27+
def test_list_displays_indices(self, cli_runner):
28+
"""Test that list command displays indices."""
29+
mock_index = MagicMock()
30+
mock_index.exists = AsyncMock(return_value=True)
31+
mock_index._redis_client = MagicMock()
32+
mock_index._redis_client.execute_command = AsyncMock(
33+
return_value=[b"num_docs", b"100"]
34+
)
35+
36+
with patch(
37+
"redis_sre_agent.core.redis.get_knowledge_index",
38+
new_callable=AsyncMock,
39+
return_value=mock_index,
40+
), patch(
41+
"redis_sre_agent.core.redis.get_schedules_index",
42+
new_callable=AsyncMock,
43+
return_value=mock_index,
44+
), patch(
45+
"redis_sre_agent.core.redis.get_threads_index",
46+
new_callable=AsyncMock,
47+
return_value=mock_index,
48+
), patch(
49+
"redis_sre_agent.core.redis.get_tasks_index",
50+
new_callable=AsyncMock,
51+
return_value=mock_index,
52+
), patch(
53+
"redis_sre_agent.core.redis.get_instances_index",
54+
new_callable=AsyncMock,
55+
return_value=mock_index,
56+
):
57+
result = cli_runner.invoke(index, ["list"])
58+
59+
assert result.exit_code == 0
60+
# Should show table with indices
61+
assert "knowledge" in result.output or "RediSearch" in result.output
62+
63+
def test_list_json_output(self, cli_runner):
64+
"""Test that --json flag outputs JSON."""
65+
mock_index = MagicMock()
66+
mock_index.exists = AsyncMock(return_value=True)
67+
mock_index._redis_client = MagicMock()
68+
mock_index._redis_client.execute_command = AsyncMock(
69+
return_value=[b"num_docs", b"50"]
70+
)
71+
72+
with patch(
73+
"redis_sre_agent.core.redis.get_knowledge_index",
74+
new_callable=AsyncMock,
75+
return_value=mock_index,
76+
), patch(
77+
"redis_sre_agent.core.redis.get_schedules_index",
78+
new_callable=AsyncMock,
79+
return_value=mock_index,
80+
), patch(
81+
"redis_sre_agent.core.redis.get_threads_index",
82+
new_callable=AsyncMock,
83+
return_value=mock_index,
84+
), patch(
85+
"redis_sre_agent.core.redis.get_tasks_index",
86+
new_callable=AsyncMock,
87+
return_value=mock_index,
88+
), patch(
89+
"redis_sre_agent.core.redis.get_instances_index",
90+
new_callable=AsyncMock,
91+
return_value=mock_index,
92+
):
93+
result = cli_runner.invoke(index, ["list", "--json"])
94+
95+
assert result.exit_code == 0
96+
import json
97+
98+
output_data = json.loads(result.output)
99+
assert isinstance(output_data, list)
100+
assert len(output_data) == 5 # 5 indices
101+
102+
103+
class TestIndexRecreateCLI:
104+
"""Test index recreate CLI command."""
105+
106+
def test_recreate_help_shows_options(self, cli_runner):
107+
"""Test that recreate command shows expected options in help."""
108+
result = cli_runner.invoke(index, ["recreate", "--help"])
109+
110+
assert result.exit_code == 0
111+
assert "--index-name" in result.output
112+
assert "--yes" in result.output
113+
assert "-y" in result.output
114+
assert "--json" in result.output
115+
assert "knowledge" in result.output
116+
assert "schedules" in result.output
117+
assert "all" in result.output
118+
119+
def test_recreate_requires_confirmation(self, cli_runner):
120+
"""Test that recreate requires confirmation without -y."""
121+
result = cli_runner.invoke(index, ["recreate"], input="n\n")
122+
123+
assert result.exit_code == 0
124+
assert "Aborted" in result.output
125+
126+
def test_recreate_with_yes_flag(self, cli_runner):
127+
"""Test that -y flag skips confirmation."""
128+
mock_result = {"success": True, "indices": {"knowledge": "recreated"}}
129+
130+
with patch(
131+
"redis_sre_agent.core.redis.recreate_indices",
132+
new_callable=AsyncMock,
133+
return_value=mock_result,
134+
) as mock_recreate:
135+
result = cli_runner.invoke(index, ["recreate", "-y"])
136+
137+
assert result.exit_code == 0
138+
mock_recreate.assert_called_once_with(None) # None means all
139+
assert "Successfully" in result.output or "✅" in result.output
140+
141+
def test_recreate_specific_index(self, cli_runner):
142+
"""Test recreating a specific index."""
143+
mock_result = {"success": True, "indices": {"knowledge": "recreated"}}
144+
145+
with patch(
146+
"redis_sre_agent.core.redis.recreate_indices",
147+
new_callable=AsyncMock,
148+
return_value=mock_result,
149+
) as mock_recreate:
150+
result = cli_runner.invoke(
151+
index, ["recreate", "--index-name", "knowledge", "-y"]
152+
)
153+
154+
assert result.exit_code == 0
155+
mock_recreate.assert_called_once_with("knowledge")
156+
157+
def test_recreate_json_output(self, cli_runner):
158+
"""Test that --json flag outputs JSON."""
159+
mock_result = {"success": True, "indices": {"knowledge": "recreated"}}
160+
161+
with patch(
162+
"redis_sre_agent.core.redis.recreate_indices",
163+
new_callable=AsyncMock,
164+
return_value=mock_result,
165+
):
166+
result = cli_runner.invoke(index, ["recreate", "--json"])
167+
168+
assert result.exit_code == 0
169+
import json
170+
171+
output_data = json.loads(result.output)
172+
assert output_data["success"] is True

0 commit comments

Comments
 (0)