Skip to content

Commit 30a432b

Browse files
E2e tests (#48)
* Mocking streamable http MCP and Dremio backend and adding e2e test * Mocking streamable http MCP and Dremio backend and adding e2e test * Mocking streamable http MCP and Dremio backend and adding e2e test
1 parent f3d8929 commit 30a432b

9 files changed

Lines changed: 421 additions & 26 deletions

File tree

.github/workflows/pytest.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,4 @@ jobs:
2626

2727
- name: Run tests
2828
run: |
29-
uv run pytest tests
29+
make test

Makefile

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Makefile for dremio-mcp testing
2+
3+
.PHONY: help test test-unit test-e2e-separate clean
4+
5+
# Default target
6+
help:
7+
@echo "Available targets:"
8+
@echo " test - Run unit tests first, then each e2e test separately"
9+
@echo " test-unit - Run only unit tests (excluding e2e)"
10+
@echo " test-e2e - Run each e2e test file separately"
11+
12+
# Main test target - runs unit tests first, then e2e separately
13+
.PHONY: test test-unit test-e2e
14+
test: test-unit test-e2e
15+
@echo "All tests completed!"
16+
17+
# Run only unit tests (excluding e2e)
18+
test-unit:
19+
@echo "Running unit tests ..."
20+
@uv run pytest tests --ignore=tests/e2e -v -x
21+
22+
# Run each e2e test file separately
23+
test-e2e:
24+
@echo "Running e2e tests ..."
25+
@for file in tests/e2e/test_*.py; do \
26+
if [ -f "$$file" ]; then \
27+
echo "Running $$file..."; \
28+
uv run pytest "$$file" -v || exit 1; \
29+
fi; \
30+
done

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ dependencies = [
3434
"requests>=2.32.3",
3535
"rich>=13.9.4",
3636
"sqlglot>=26.23.0",
37+
"starlette>=0.46.1",
3738
"structlog>=25.1.0",
3839
"typer>=0.15.2",
3940
"uvicorn>=0.34.0",

tests/config/test_settings.py

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -25,28 +25,6 @@
2525
from dremioai.config.tools import ToolType
2626

2727

28-
@pytest.fixture
29-
def temp_config_dir():
30-
"""Create a temporary directory for config files"""
31-
with TemporaryDirectory() as temp_dir:
32-
yield Path(temp_dir)
33-
34-
35-
@pytest.fixture
36-
def mock_config_dir(temp_config_dir):
37-
"""Mock the home directory to use our temporary directory"""
38-
with patch.object(Path, "home", return_value=temp_config_dir):
39-
# Also patch XDG_CONFIG_HOME environment variable
40-
old_env = os.environ.get("XDG_CONFIG_HOME")
41-
os.environ["XDG_CONFIG_HOME"] = str(temp_config_dir)
42-
yield temp_config_dir
43-
# Restore original environment
44-
if old_env:
45-
os.environ["XDG_CONFIG_HOME"] = old_env
46-
else:
47-
os.environ.pop("XDG_CONFIG_HOME", None)
48-
49-
5028
def test_configure_with_no_file_works(mock_config_dir):
5129
s = settings.instance()
5230
assert settings.instance() is not None

tests/conftest.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,29 @@
1818
Global pytest fixtures for dremio-mcp tests.
1919
"""
2020
import os
21+
import random
22+
from typing import AsyncGenerator, NamedTuple
23+
2124
import pytest
2225
from pathlib import Path
2326
from tempfile import TemporaryDirectory
2427
from unittest.mock import patch
28+
from collections import OrderedDict
2529

2630
from dremioai.config import settings
2731
from dremioai.config.tools import ToolType
32+
from dremioai.servers.mcp import Transports, init
33+
34+
from mocks.http_mock import (
35+
create_pytest_logging_server_fixture,
36+
start_server,
37+
ServerFixture,
38+
LoggingServerFixture,
39+
)
40+
from mcp import ClientSession
41+
from mcp.client.streamable_http import streamablehttp_client
42+
import contextlib
43+
from dremioai.log import set_level
2844

2945

3046
@pytest.fixture
@@ -69,3 +85,138 @@ def mock_settings_instance():
6985
yield settings.instance()
7086
finally:
7187
settings._settings.set(old_settings)
88+
89+
90+
@pytest.fixture
91+
def temp_config_dir():
92+
"""Create a temporary directory for config files"""
93+
with TemporaryDirectory() as temp_dir:
94+
yield Path(temp_dir)
95+
96+
97+
@pytest.fixture
98+
def mock_config_dir(temp_config_dir):
99+
"""Mock the home directory to use our temporary directory"""
100+
with patch.object(Path, "home", return_value=temp_config_dir):
101+
# Also patch XDG_CONFIG_HOME environment variable
102+
old_env = os.environ.get("XDG_CONFIG_HOME")
103+
os.environ["XDG_CONFIG_HOME"] = str(temp_config_dir)
104+
yield temp_config_dir
105+
# Restore original environment
106+
if old_env:
107+
os.environ["XDG_CONFIG_HOME"] = old_env
108+
else:
109+
os.environ.pop("XDG_CONFIG_HOME", None)
110+
111+
112+
def _create_logging_server(log_level="warning"):
113+
# Mock data for HTTP endpoints that tools will call
114+
mock_data = OrderedDict(
115+
[
116+
(r"/sql", "sql/job_submission.json"), # SQL query submission
117+
(r"/job/test-job-12345$", "sql/job_status.json"), # Job status check
118+
(r"/job/test-job-12345/results$", "sql/job_results.json"), # Job results
119+
(r"/search", "search/search_results.json"), # Search endpoints
120+
(r"/catalog/.*/wiki", "catalog/wiki.json"), # Wiki endpoints
121+
(r"/catalog/.*/tags", "catalog/tags.json"), # Tags endpoints
122+
(r"/catalog/.*/graph", "catalog/lineage.json"), # Lineage endpoints
123+
(r"/catalog(/by-path)?", "catalog/table_schema.json"), # Schema endpoints
124+
]
125+
)
126+
127+
return create_pytest_logging_server_fixture(
128+
mock_data=mock_data, port=8000, log_level=log_level
129+
)
130+
131+
132+
@pytest.fixture(scope="module")
133+
def logging_level(request):
134+
return "info"
135+
if request.config.get_verbosity() > 2:
136+
return "debug"
137+
if request.config.get_verbosity() > 1:
138+
return "info"
139+
return "warning"
140+
141+
142+
@pytest.fixture(scope="module")
143+
def logging_server(logging_level):
144+
server = _create_logging_server(logging_level)
145+
try:
146+
yield server
147+
finally:
148+
try:
149+
server.close()
150+
except:
151+
from rich import traceback
152+
153+
traceback.print_exc()
154+
155+
156+
class StreamableMcpServerFixture(NamedTuple):
157+
mcp_server: ServerFixture
158+
logging_server: LoggingServerFixture
159+
160+
161+
@pytest.fixture
162+
def http_streamable_mcp_server(logging_server, mock_config_dir, logging_level):
163+
old = settings.instance()
164+
sf = None
165+
try:
166+
settings.configure(force=True)
167+
settings._settings.set(
168+
settings.Settings.model_validate(
169+
{
170+
"dremio": {
171+
"uri": logging_server.url,
172+
"project_id": "test-project-id",
173+
"pat": "test-pat",
174+
"enable_search": True,
175+
},
176+
"tools": {"server_mode": ToolType.FOR_DATA_PATTERNS.name},
177+
}
178+
)
179+
)
180+
settings.write_settings()
181+
port = random.randrange(9000, 12000)
182+
set_level(logging_level.upper())
183+
mcp_server = init(
184+
transport=Transports.streamable_http,
185+
port=port,
186+
mode=settings.instance().tools.server_mode,
187+
)
188+
189+
def should_exit(v: bool):
190+
mcp_server.should_exit = v
191+
192+
server, stop_event = start_server(
193+
mcp_server.run_streamable_http_async(), should_exit
194+
)
195+
sf = ServerFixture(f"http://127.0.0.1:{port}/mcp/", stop_event, server)
196+
yield StreamableMcpServerFixture(sf, logging_server)
197+
finally:
198+
if sf is not None:
199+
try:
200+
sf.close()
201+
except:
202+
from rich import traceback
203+
204+
traceback.print_exc()
205+
print(f"{sf} closed")
206+
settings._settings.set(old)
207+
208+
209+
@contextlib.asynccontextmanager
210+
async def http_streamable_client_server(
211+
sf: ServerFixture, token=None
212+
) -> AsyncGenerator[ClientSession]:
213+
headers = {"Authorization": f"Bearer {token}"} if token is not None else None
214+
async with streamablehttp_client(url=sf.url, headers=headers) as (
215+
read_stream,
216+
write_stream,
217+
gid,
218+
):
219+
print(f"Client connected to {sf.url}")
220+
async with ClientSession(read_stream, write_stream) as session:
221+
await session.initialize()
222+
yield session

tests/e2e/test_e2e_pat.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import pytest
2+
from mcp.types import CallToolResult
3+
from conftest import http_streamable_client_server
4+
5+
6+
@pytest.mark.asyncio
7+
async def test_tool_pat(http_streamable_mcp_server):
8+
async with http_streamable_client_server(
9+
http_streamable_mcp_server.mcp_server,
10+
token="my-token",
11+
) as session:
12+
result: CallToolResult = await session.call_tool(
13+
"RunSqlQuery", {"s": "SELECT 1"}
14+
)
15+
assert result.structuredContent["result"]["result"][0]["test_column"] == 1
16+
for le in http_streamable_mcp_server.logging_server.logs():
17+
assert (
18+
le.headers.get("authorization") == "Bearer my-token"
19+
), f"{le} does not have the right auth header"

tests/e2e/test_mcp_e2e.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import pytest
2+
from conftest import http_streamable_client_server
3+
4+
from dremioai.tools.tools import get_tools
5+
from dremioai.config import settings
6+
7+
8+
@pytest.mark.asyncio
9+
async def test_basic(http_streamable_mcp_server):
10+
async with http_streamable_client_server(
11+
http_streamable_mcp_server.mcp_server
12+
) as session:
13+
lts = await session.list_tools()
14+
tr = {t.name for t in lts.tools}
15+
assert tr == {
16+
t.__name__ for t in get_tools(For=settings.instance().tools.server_mode)
17+
}

0 commit comments

Comments
 (0)