Skip to content

Commit b143554

Browse files
socksyclaude
andcommitted
feat: add BDD integration tests for MCP server with mock API
- Add comprehensive BDD test suite using behave for MCP functionality - Create mock FastAPI server for testing without real Tower API dependency - Add GitHub Actions workflow for automated integration testing - Implement MCPClient and MCPTestHelper for real MCP server interaction - Support mock server configuration via TOWER_MOCK_API_URL environment variable - Add timeout handling and proper cleanup for test scenarios - Remove redundant comments and simplify code structure 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 65bd852 commit b143554

14 files changed

Lines changed: 1531 additions & 79 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
name: Integration Tests
2+
3+
on:
4+
push:
5+
branches: [main, 'ben/*'] # Run on main branch and personal branches
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
integration-tests:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- name: Checkout code
15+
uses: actions/checkout@v4
16+
17+
- name: Install Nix
18+
uses: cachix/install-nix-action@v24
19+
with:
20+
github_access_token: ${{ secrets.GITHUB_TOKEN }}
21+
22+
- name: Build Tower CLI
23+
run: |
24+
nix develop --command cargo build
25+
26+
- name: Set up Python and dependencies
27+
run: |
28+
nix develop --command bash -c "
29+
cd mock-api-server &&
30+
python -m venv .venv &&
31+
source .venv/bin/activate &&
32+
pip install -e .
33+
"
34+
35+
- name: Start mock API server
36+
run: |
37+
cd mock-api-server
38+
source .venv/bin/activate
39+
uvicorn main:app --host 127.0.0.1 --port 8000 &
40+
echo $! > mock_server.pid
41+
42+
# Wait for server to be ready
43+
for i in {1..30}; do
44+
if curl -f http://127.0.0.1:8000/health 2>/dev/null; then
45+
echo "Mock server is ready"
46+
break
47+
fi
48+
echo "Waiting for mock server to start... ($i/30)"
49+
sleep 1
50+
done
51+
52+
- name: Run BDD integration tests
53+
run: |
54+
cd tests/integration
55+
nix develop --command bash -c "
56+
TOWER_MOCK_API_URL=http://127.0.0.1:8000 behave features/
57+
"
58+
59+
- name: Stop mock server
60+
if: always()
61+
run: |
62+
if [ -f mock-api-server/mock_server.pid ]; then
63+
kill $(cat mock-api-server/mock_server.pid) || true
64+
fi

‎flake.nix‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@
271271
echo "Tower CLI development environment"
272272
echo "Python: $(python --version)"
273273
echo "Rust: $(rustc --version)"
274+
echo "Behave: $(behave --version 2>/dev/null || echo 'not available')"
274275
'';
275276
};
276277

‎mock-api-server/main.py‎

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
from fastapi import FastAPI, HTTPException, Response
2+
from pydantic import BaseModel
3+
from typing import List, Dict, Any, Optional
4+
import os
5+
import json
6+
import datetime
7+
8+
app = FastAPI(
9+
title="Tower Mock API",
10+
description="A mock API server for Tower CLI testing.",
11+
version="1.0.0",
12+
)
13+
14+
# In-memory data stores for mock responses
15+
mock_apps_db = {}
16+
mock_secrets_db = {}
17+
mock_teams_db = {}
18+
mock_runs_db = {}
19+
20+
# Helper to generate unique IDs
21+
def generate_id():
22+
return str(datetime.datetime.now().timestamp()).replace(".", "")
23+
24+
@app.get("/")
25+
async def read_root():
26+
return {"message": "Tower Mock API is running!"}
27+
28+
# Placeholder for /v1/apps endpoints
29+
@app.get("/v1/apps")
30+
async def list_apps():
31+
return {"apps": list(mock_apps_db.values()), "pages": {"page": 1, "total": len(mock_apps_db), "num_pages": 1, "page_size": 20}}
32+
33+
@app.post("/v1/apps")
34+
async def create_app(app_data: Dict[str, Any]):
35+
app_name = app_data.get("name")
36+
if not app_name:
37+
raise HTTPException(status_code=400, detail="App name is required")
38+
if app_name in mock_apps_db:
39+
raise HTTPException(status_code=409, detail=f"App '{app_name}' already exists")
40+
41+
new_app = {
42+
"name": app_name,
43+
"owner": "mock_owner",
44+
"short_description": app_data.get("short_description", ""),
45+
"version": "1.0.0",
46+
"schedule": None,
47+
"created_at": datetime.datetime.now().isoformat(),
48+
"next_run_at": None,
49+
"health_status": "healthy",
50+
"run_results": {
51+
"cancelled": 0, "crashed": 0, "errored": 0, "exited": 0, "pending": 0, "running": 0
52+
}
53+
}
54+
mock_apps_db[app_name] = new_app
55+
return {"app": new_app}
56+
57+
@app.get("/v1/apps/{name}")
58+
async def describe_app(name: str):
59+
app_info = mock_apps_db.get(name)
60+
if not app_info:
61+
raise HTTPException(status_code=404, detail=f"App '{name}' not found")
62+
return {"app": app_info, "runs": []} # Simplistic, no runs yet
63+
64+
@app.delete("/v1/apps/{name}")
65+
async def delete_app(name: str):
66+
if name not in mock_apps_db:
67+
raise HTTPException(status_code=404, detail=f"App '{name}' not found")
68+
deleted_app = mock_apps_db.pop(name)
69+
return {"app": deleted_app}
70+
71+
@app.post("/v1/apps/{name}/deploy")
72+
async def deploy_app(name: str, response: Response):
73+
if name not in mock_apps_db:
74+
raise HTTPException(status_code=404, detail=f"App '{name}' not found")
75+
# Simulate a successful deployment
76+
version_num = "1.0.0" # Simplified versioning
77+
deployed_version = {
78+
"version": version_num,
79+
"parameters": [],
80+
"created_at": datetime.datetime.now().isoformat(),
81+
"towerfile": "mock_towerfile_content"
82+
}
83+
# Update app's version
84+
mock_apps_db[name]["version"] = version_num
85+
return {"version": deployed_version}
86+
87+
@app.post("/v1/apps/{name}/runs")
88+
async def run_app(name: str, run_params: Dict[str, Any]):
89+
if name not in mock_apps_db:
90+
raise HTTPException(status_code=404, detail=f"App '{name}' not found")
91+
92+
run_id = generate_id()
93+
new_run = {
94+
"$link": f"/runs/{run_id}",
95+
"run_id": run_id,
96+
"number": len(mock_runs_db) + 1,
97+
"app_name": name,
98+
"status": "running",
99+
"status_group": "",
100+
"parameters": [{"name": k, "value": v} for k, v in run_params.get("parameters", {}).items()],
101+
"environment": run_params.get("environment", "default"),
102+
"exit_code": None,
103+
"created_at": datetime.datetime.now().isoformat(),
104+
"scheduled_at": datetime.datetime.now().isoformat(),
105+
"cancelled_at": None,
106+
"started_at": datetime.datetime.now().isoformat(),
107+
"ended_at": None,
108+
"app_version": mock_apps_db[name].get("version", "1.0.0")
109+
}
110+
mock_runs_db[run_id] = new_run
111+
return {"run": new_run}
112+
113+
# Placeholder for /secrets endpoints
114+
@app.get("/v1/secrets")
115+
async def list_secrets():
116+
return {"secrets": list(mock_secrets_db.values()), "pages": {"page": 1, "total": len(mock_secrets_db), "num_pages": 1, "page_size": 20}}
117+
118+
@app.post("/v1/secrets")
119+
async def create_secret(secret_data: Dict[str, Any]):
120+
secret_name = secret_data.get("name")
121+
environment = secret_data.get("environment", "default")
122+
key = f"{environment}/{secret_name}"
123+
if not secret_name:
124+
raise HTTPException(status_code=400, detail="Secret name is required")
125+
if key in mock_secrets_db:
126+
raise HTTPException(status_code=409, detail=f"Secret '{secret_name}' in environment '{environment}' already exists")
127+
128+
new_secret = {
129+
"name": secret_name,
130+
"environment": environment,
131+
"preview": secret_data.get("preview", "******"),
132+
"created_at": datetime.datetime.now().isoformat()
133+
}
134+
mock_secrets_db[key] = new_secret
135+
return {"secret": new_secret}
136+
137+
@app.delete("/v1/secrets/{name}")
138+
async def delete_secret(name: str, environment: str = "default"):
139+
key = f"{environment}/{name}"
140+
if key not in mock_secrets_db:
141+
raise HTTPException(status_code=404, detail=f"Secret '{name}' in environment '{environment}' not found")
142+
deleted_secret = mock_secrets_db.pop(key)
143+
return {"secret": deleted_secret}
144+
145+
# Placeholder for /teams endpoints
146+
@app.get("/v1/teams")
147+
async def list_teams():
148+
return {"teams": list(mock_teams_db.values())}
149+
150+
@app.post("/v1/teams")
151+
async def create_team(team_data: Dict[str, Any]):
152+
team_name = team_data.get("name")
153+
if not team_name:
154+
raise HTTPException(status_code=400, detail="Team name is required")
155+
if team_name in mock_teams_db:
156+
raise HTTPException(status_code=409, detail=f"Team '{team_name}' already exists")
157+
158+
new_team = {
159+
"name": team_name,
160+
"type": "team",
161+
"token": {"jwt": "mock_jwt_token"}
162+
}
163+
mock_teams_db[team_name] = new_team
164+
return {"team": new_team}
165+
166+
@app.put("/v1/teams/{name}")
167+
async def switch_team(name: str):
168+
if name not in mock_teams_db:
169+
raise HTTPException(status_code=404, detail=f"Team '{name}' not found")
170+
# In a real scenario, this would involve updating the session/context
171+
# For mock, we just return the team
172+
return {"team": mock_teams_db[name]}
173+
174+
# Additional endpoints for MCP server support
175+
@app.get("/v1/secrets/key")
176+
async def describe_secrets_key():
177+
"""Mock endpoint for getting secrets encryption key."""
178+
# Return a mock RSA public key
179+
mock_public_key = """-----BEGIN RSA PUBLIC KEY-----
180+
MIIBCgKCAQEA2Z9QjRbVnqcXl6BjJpHhQY6LKhGkQY4nQSLp5QGx8xQzS1l5mKoT
181+
2aJzQbJzXzJdQtJzMzJmYzY3YzIzMzJmYzMzNzJlYzNkYzNmYzQ1YzQ2YzQ3YzQ4
182+
2Z9QjRbVnqcXl6BjJpHhQY6LKhGkQY4nQSLp5QGx8xQzS1l5mKoT2aJzQbJzXzJd
183+
QzJmYzMzNzJlYzNkYzNmYzQ1YzQ2YzQ3YzQ4YzQ5YzUwYzUxYzUyYzUzYzU0YzU1
184+
YzU2YzU3YzU4YzU5YzYwYzYxYzYyYzYzYzY0YzY1YzY2YzY3YzY4YzY5YzZhYzZi
185+
YzZjYzZkYzZlYzZmYzc0YzQ1YzQ2YzQ3YzQ4YzQ5YzUwYzUxYzUyYzUzYzU0YzU1
186+
QIDAQAB
187+
-----END RSA PUBLIC KEY-----"""
188+
return {"public_key": mock_public_key}
189+
190+
@app.post("/v1/session/refresh")
191+
async def refresh_session(refresh_params: Dict[str, Any] = None):
192+
"""Mock endpoint for refreshing session."""
193+
return {
194+
"user": {
195+
"id": "mock_user_id",
196+
"email": "test@example.com"
197+
},
198+
"teams": [
199+
{
200+
"name": "default",
201+
"type": "user",
202+
"token": {"jwt": "mock_jwt_token"}
203+
}
204+
],
205+
"active_team": {
206+
"name": "default",
207+
"type": "user",
208+
"token": {"jwt": "mock_jwt_token"}
209+
}
210+
}
211+
212+
@app.get("/v1/apps/{name}/runs/{seq}/logs")
213+
async def describe_run_logs(name: str, seq: int):
214+
"""Mock endpoint for getting run logs."""
215+
if name not in mock_apps_db:
216+
raise HTTPException(status_code=404, detail=f"App '{name}' not found")
217+
218+
# Return mock log entries
219+
return {
220+
"log_lines": [
221+
{"timestamp": "2025-08-22T12:00:00Z", "message": "Starting application..."},
222+
{"timestamp": "2025-08-22T12:00:01Z", "message": "Hello, World!"},
223+
{"timestamp": "2025-08-22T12:00:02Z", "message": "Application completed successfully"}
224+
]
225+
}
226+
227+
# Health check for testing
228+
@app.get("/health")
229+
async def health_check():
230+
return {"status": "healthy", "timestamp": datetime.datetime.now().isoformat()}

‎mock-api-server/pyproject.toml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[project]
2+
name = "tower-mock-api"
3+
version = "0.1.0"
4+
description = "A mock API server for Tower CLI testing."
5+
dependencies = [
6+
"fastapi==0.111.0",
7+
"uvicorn==0.30.1",
8+
]
9+
requires-python = ">=3.9"
10+
11+
[build-system]
12+
requires = ["setuptools>=61.0"]
13+
build-backend = "setuptools.build_meta"

‎mock-api-server/run.sh‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/bin/bash
2+
3+
# Exit immediately if a command exits with a non-zero status.
4+
set -e
5+
6+
# Ensure uv is available
7+
if ! command -v uv &> /dev/null
8+
then
9+
echo "uv could not be found. Please install it (e.g., pip install uv)."
10+
exit 1
11+
fi
12+
13+
# Create a virtual environment if it doesn't exist and install dependencies
14+
echo "Creating virtual environment and installing dependencies with uv..."
15+
uv venv
16+
source .venv/bin/activate
17+
uv pip install -e .
18+
19+
# Run the FastAPI application
20+
echo "Starting FastAPI mock server..."
21+
uvicorn main:app --host 0.0.0.0 --port 8000
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Metadata-Version: 2.4
2+
Name: tower-mock-api
3+
Version: 0.1.0
4+
Summary: A mock API server for Tower CLI testing.
5+
Requires-Python: >=3.9
6+
Requires-Dist: fastapi==0.111.0
7+
Requires-Dist: uvicorn==0.30.1
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
main.py
2+
pyproject.toml
3+
tower_mock_api.egg-info/PKG-INFO
4+
tower_mock_api.egg-info/SOURCES.txt
5+
tower_mock_api.egg-info/dependency_links.txt
6+
tower_mock_api.egg-info/requires.txt
7+
tower_mock_api.egg-info/top_level.txt
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
fastapi==0.111.0
2+
uvicorn==0.30.1
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
main

0 commit comments

Comments
 (0)