Skip to content

Commit f47920d

Browse files
socksyclaude
andauthored
fix: align BDD tests and mock server with real API behavior (#198)
* fix(mock): use RFC 7807 format for 422 validation response Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(mock): match real server warning event and log stream format Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(mock): add name field to schedule update endpoint Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: update BDD assertions to match real server responses Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(test-runner): support running tests against external server Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: make CLI build location configurable in tests * fix: TOWER_JWT being set makes it ignore the actual mock one in the repo 🤦 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1017492 commit f47920d

6 files changed

Lines changed: 127 additions & 55 deletions

File tree

tests/integration/features/cli_runs.feature

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Feature: CLI Run Commands
1919
Given I have a valid Towerfile in the current directory
2020
When I run "tower deploy --create" via CLI
2121
Then I run "tower run -p nonexistent_param=test" via CLI
22-
Then the output should show "API Error:"
22+
Then the output should show "Error details:"
2323
And the output should show "Validation error"
2424
And the output should show "Unknown parameter"
2525
And the output should not just show "422"
@@ -36,20 +36,18 @@ Feature: CLI Run Commands
3636
Given I have a simple hello world application named "app-logs-after-completion"
3737
When I run "tower deploy --create" via CLI
3838
And I run "tower run" via CLI
39-
Then the output should show "First log before run completes"
40-
And the output should show "Second log after run completes"
39+
Then the output should show "Hello, World!"
4140

4241
Scenario: CLI apps logs follow should stream logs and drain after completion
4342
Given I have a simple hello world application named "app-logs-after-completion"
4443
When I run "tower deploy --create" via CLI
4544
And I run "tower run --detached" via CLI and capture run number
4645
And I run "tower apps logs --follow {app_name}#{run_number}" via CLI using created app name and run number
47-
Then the output should show "First log before run completes"
48-
And the output should show "Second log after run completes"
46+
Then the output should show "Hello, World!"
4947

5048
Scenario: CLI apps logs follow should display warnings
5149
Given I have a simple hello world application named "app-logs-warning"
5250
When I run "tower deploy --create" via CLI
5351
And I run "tower run --detached" via CLI and capture run number
5452
And I run "tower apps logs --follow {app_name}#{run_number}" via CLI using created app name and run number
55-
Then the output should show "Warning: Rate limit approaching"
53+
Then the output should show "Warning: No new logs available"

tests/integration/features/environment.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ def after_scenario(context, scenario):
4545

4646

4747
def _find_tower_binary():
48+
if binary := os.environ.get("TOWER_CLI_BINARY"):
49+
if Path(binary).exists():
50+
return binary
51+
4852
# Look for debug build first
4953
debug_path = (
5054
Path(__file__).parent.parent.parent.parent / "target" / "debug" / "tower"

tests/integration/features/mcp_app_management.feature

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ Feature: MCP App Management
100100
When I call tower_deploy via MCP
101101
Then I call tower_run_remote with invalid parameter "nonexistent_param=test"
102102
Then I should receive a detailed validation error
103-
And the error should mention "Unknown parameter"
103+
And the error should mention "Validation error"
104104
And the error should not just be a status code
105105

106106
Scenario: Local run should detect exit code failures

tests/integration/features/steps/cli_steps.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def step_run_cli_command(context, command):
3030
test_env = os.environ.copy()
3131
test_env["FORCE_COLOR"] = "1" # Force colored output
3232
test_env["CLICOLOR_FORCE"] = "1" # Force colored output
33-
test_env["TOWER_URL"] = context.tower_url # Use mock API
33+
test_env["TOWER_URL"] = context.tower_url # Use configured API URL
3434

3535
# Override HOME to use test session (which contains auth credentials)
3636
test_home = Path(__file__).parent.parent.parent / "test-home"
@@ -44,9 +44,11 @@ def step_run_cli_command(context, command):
4444
env=test_env,
4545
)
4646
context.cli_output = result.stdout + result.stderr
47+
context.cli_stdout = result.stdout
4748
context.cli_return_code = result.returncode
4849
except subprocess.TimeoutExpired:
4950
context.cli_output = "Command timed out"
51+
context.cli_stdout = ""
5052
context.cli_return_code = 124
5153
except Exception as e:
5254
print(f"DEBUG: Exception in CLI command: {type(e).__name__}: {e}")
@@ -266,11 +268,17 @@ def step_table_should_show_columns(context, column_list):
266268
assert column in output, f"Expected column '{column}' in table, got: {output}"
267269

268270

271+
def parse_cli_json(context):
272+
"""Parse JSON from CLI stdout (excludes stderr)."""
273+
raw = getattr(context, "cli_stdout", context.cli_output)
274+
return json.loads(raw)
275+
276+
269277
@step("the output should be valid JSON")
270278
def step_output_should_be_valid_json(context):
271279
"""Verify output is valid JSON"""
272280
try:
273-
json.loads(context.cli_output)
281+
parse_cli_json(context)
274282
except json.JSONDecodeError as e:
275283
raise AssertionError(
276284
f"Output is not valid JSON: {e}\nOutput: {context.cli_output}"
@@ -280,7 +288,7 @@ def step_output_should_be_valid_json(context):
280288
@step("the JSON should contain app information")
281289
def step_json_should_contain_app_info(context):
282290
"""Verify JSON contains app-related information"""
283-
data = json.loads(context.cli_output)
291+
data = parse_cli_json(context)
284292
assert (
285293
"app" in data or "name" in data
286294
), f"Expected app information in JSON, got: {data}"
@@ -289,7 +297,7 @@ def step_json_should_contain_app_info(context):
289297
@step("the JSON should contain runs array")
290298
def step_json_should_contain_runs_array(context):
291299
"""Verify JSON contains runs array"""
292-
data = json.loads(context.cli_output)
300+
data = parse_cli_json(context)
293301
assert "runs" in data and isinstance(
294302
data["runs"], list
295303
), f"Expected runs array in JSON, got: {data}"
@@ -298,7 +306,7 @@ def step_json_should_contain_runs_array(context):
298306
@step("the JSON should contain the created app information")
299307
def step_json_should_contain_created_app_info(context):
300308
"""Verify JSON contains created app information"""
301-
data = json.loads(context.cli_output)
309+
data = parse_cli_json(context)
302310

303311
expected = IsPartialDict(
304312
result="success",
@@ -318,7 +326,7 @@ def step_json_should_contain_created_app_info(context):
318326
@step('the app name should be "{expected_name}"')
319327
def step_app_name_should_be(context, expected_name):
320328
"""Verify app name matches expected value"""
321-
data = json.loads(context.cli_output)
329+
data = parse_cli_json(context)
322330
# Extract app name from response structure
323331
if "app" in data and "name" in data["app"]:
324332
actual_name = data["app"]["name"]
@@ -337,7 +345,7 @@ def step_app_name_should_be(context, expected_name):
337345
@step('the app description should be "{expected_description}"')
338346
def step_app_description_should_be(context, expected_description):
339347
"""Verify app description matches expected value"""
340-
data = json.loads(context.cli_output)
348+
data = parse_cli_json(context)
341349
candidates = []
342350

343351
if "app" in data:

tests/integration/run_tests.py

Lines changed: 69 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
"""
33
Simple test runner for Tower MCP integration tests.
44
Assumes dependencies are already installed via nix devShell.
5+
6+
Supports two modes:
7+
1. Mock mode (default): Starts local mock server at http://127.0.0.1:8000
8+
2. Real server mode: Set TOWER_URL env var to use external server
9+
- Skips mock server startup
10+
- Preserves externally configured session.json
511
"""
612

713
import os
@@ -18,12 +24,41 @@ def log(msg):
1824
print(f"\033[36m[test-runner]\033[0m {msg}")
1925

2026

27+
def is_session_externally_configured(test_home):
28+
"""Check if session.json has been externally configured.
29+
30+
Returns True if session.json is newer than the git HEAD version,
31+
indicating it was set up by external orchestration (e.g., from monorepo).
32+
"""
33+
session_file = test_home / ".config" / "tower" / "session.json"
34+
if not session_file.exists():
35+
return False
36+
37+
try:
38+
result = subprocess.run(
39+
["git", "log", "-1", "--format=%ct", str(session_file)],
40+
capture_output=True,
41+
text=True,
42+
check=True,
43+
)
44+
git_mtime = int(result.stdout.strip())
45+
file_mtime = int(session_file.stat().st_mtime)
46+
return file_mtime > git_mtime
47+
except (subprocess.CalledProcessError, ValueError, FileNotFoundError):
48+
return False
49+
50+
2151
def reset_session_fixture(test_home):
2252
"""Reset the session.json fixture to its committed state before tests.
2353
2454
The CLI may modify session.json during MCP operations (like team switching),
2555
so we restore it to the canonical committed version before each test run.
56+
Skips reset if session appears to be externally configured.
2657
"""
58+
if is_session_externally_configured(test_home):
59+
log("Skipping session.json reset (externally configured)")
60+
return
61+
2762
session_file = test_home / ".config" / "tower" / "session.json"
2863
subprocess.run(
2964
["git", "checkout", str(session_file)],
@@ -68,7 +103,11 @@ def start_mock_server():
68103

69104
def main():
70105
"""Run the integration tests."""
71-
# Check prerequisites - look for tower binary from cargo build or on PATH
106+
# Check prerequisites - look for tower binary from env, cargo build, or PATH
107+
has_env_binary = bool(
108+
os.environ.get("TOWER_CLI_BINARY")
109+
and Path(os.environ["TOWER_CLI_BINARY"]).exists()
110+
)
72111
project_root = Path(__file__).parent.parent.parent
73112
has_cargo_binary = any(
74113
(project_root / "target" / build / "tower").exists()
@@ -80,7 +119,7 @@ def main():
80119
else False
81120
)
82121

83-
if not has_cargo_binary and not has_path_binary:
122+
if not has_env_binary and not has_cargo_binary and not has_path_binary:
84123
log(
85124
"ERROR: Tower binary not found. Please run 'cargo build' or 'maturin develop' first."
86125
)
@@ -96,32 +135,40 @@ def main():
96135

97136
# Set up environment
98137
env = os.environ.copy()
99-
if "TOWER_URL" not in env:
100-
env["TOWER_URL"] = "http://127.0.0.1:8000"
101138

102139
# Set HOME to test-home directory to isolate session from user's real config
103140
test_home = Path(__file__).parent / "test-home"
104141
env["HOME"] = str(test_home.absolute())
105142

106-
log(f"Using API URL: \033[1m{env['TOWER_URL']}\033[0m")
107-
log(f"Using test HOME: \033[1m{env['HOME']}\033[0m")
108-
109-
# Ensure mock server is running
110-
mock_process = None
111-
if not check_mock_server_health(env["TOWER_URL"]):
112-
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
113-
port_in_use = sock.connect_ex(("127.0.0.1", 8000)) == 0
114-
sock.close()
115-
116-
if port_in_use:
117-
log(
118-
"ERROR: Port 8000 is in use but not responding to health check (some unrelated server?)."
119-
)
120-
return 1
121-
122-
mock_process = start_mock_server()
143+
# Determine if we're using external configuration or mock server
144+
tower_url_preset = "TOWER_URL" in os.environ
145+
if tower_url_preset:
146+
server_url = env["TOWER_URL"]
147+
mock_process = None
148+
log(f"Using externally configured API URL: \033[1m{server_url}\033[0m")
123149
else:
124-
log("Mock server already running and healthy")
150+
server_url = "http://127.0.0.1:8000"
151+
env["TOWER_URL"] = server_url
152+
log(f"Using mock server API URL: \033[1m{server_url}\033[0m")
153+
154+
# Ensure mock server is running
155+
mock_process = None
156+
if not check_mock_server_health(server_url):
157+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
158+
port_in_use = sock.connect_ex(("127.0.0.1", 8000)) == 0
159+
sock.close()
160+
161+
if port_in_use:
162+
log(
163+
"ERROR: Port 8000 is in use but not responding to health check (some unrelated server?)."
164+
)
165+
return 1
166+
167+
mock_process = start_mock_server()
168+
else:
169+
log("Mock server already running and healthy")
170+
171+
log(f"Using test HOME: \033[1m{env['HOME']}\033[0m")
125172

126173
# Actually run tests
127174
try:

tests/mock-api-server/main.py

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"""
1212

1313
from fastapi import FastAPI, HTTPException, Response, Request
14-
from fastapi.responses import StreamingResponse
14+
from fastapi.responses import JSONResponse, StreamingResponse
1515
from pydantic import BaseModel
1616
from typing import List, Dict, Any, Optional
1717
import os
@@ -247,13 +247,20 @@ async def run_app(name: str, run_params: Dict[str, Any]):
247247

248248
parameters = run_params.get("parameters", {})
249249
if "nonexistent_param" in parameters:
250-
raise HTTPException(
250+
return JSONResponse(
251251
status_code=422,
252-
detail={
253-
"detail": "Validation error",
254-
"status": 422,
252+
content={
253+
"$schema": "http://localhost:8081/v1/schemas/ErrorModel.json",
255254
"title": "Unprocessable Entity",
256-
"errors": [{"message": "Unknown parameter"}],
255+
"status": 422,
256+
"detail": "Validation error",
257+
"errors": [
258+
{
259+
"message": "Unknown parameter",
260+
"location": "body.parameters",
261+
"value": parameters,
262+
}
263+
],
257264
},
258265
)
259266

@@ -530,7 +537,7 @@ def make_log_event(seq: int, line_num: int, content: str, timestamp: str):
530537

531538

532539
def make_warning_event(content: str, timestamp: str):
533-
data = {"data": {"content": content, "reported_at": timestamp}, "event": "warning"}
540+
data = {"content": content, "reported_at": timestamp}
534541
return f"event: warning\ndata: {json.dumps(data)}\n\n"
535542

536543

@@ -549,25 +556,31 @@ async def describe_run_logs(name: str, seq: int):
549556

550557

551558
async def generate_logs_after_completion_test_stream(seq: int):
552-
"""Log before run completion, then log after.
553-
554-
Timeline: Run completes at 1 second, second log sent at 1.5 seconds.
555-
"""
559+
"""Emit realistic runner logs then close, matching real server behavior."""
560+
yield make_log_event(seq, 1, "Using CPython 3.12.9", "2025-08-22T12:00:00Z")
556561
yield make_log_event(
557-
seq, 1, "First log before run completes", "2025-08-22T12:00:00Z"
562+
seq, 2, "Creating virtual environment at: .venv", "2025-08-22T12:00:00Z"
558563
)
559-
await asyncio.sleep(1.5)
564+
await asyncio.sleep(0.5)
560565
yield make_log_event(
561-
seq, 2, "Second log after run completes", "2025-08-22T12:00:01Z"
566+
seq, 3, "Activate with: source .venv/bin/activate", "2025-08-22T12:00:01Z"
562567
)
568+
yield make_log_event(seq, 4, "Hello, World!", "2025-08-22T12:00:01Z")
563569

564570

565571
async def generate_warning_log_stream(seq: int):
566-
"""Stream a warning and a couple of logs, then finish."""
567-
yield make_warning_event("Rate limit approaching", "2025-08-22T12:00:00Z")
568-
yield make_log_event(seq, 1, "Warning stream log 1", "2025-08-22T12:00:00Z")
569-
await asyncio.sleep(1.2)
570-
yield make_log_event(seq, 2, "Warning stream log 2", "2025-08-22T12:00:01Z")
572+
"""Stream logs then emit warning before closing, matching real server behavior."""
573+
yield make_log_event(seq, 1, "Using CPython 3.12.9", "2025-08-22T12:00:00Z")
574+
yield make_log_event(
575+
seq, 2, "Creating virtual environment at: .venv", "2025-08-22T12:00:00Z"
576+
)
577+
await asyncio.sleep(0.5)
578+
yield make_log_event(
579+
seq, 3, "Activate with: source .venv/bin/activate", "2025-08-22T12:00:00Z"
580+
)
581+
yield make_log_event(seq, 4, "Hello, World!", "2025-08-22T12:00:01Z")
582+
await asyncio.sleep(0.5)
583+
yield make_warning_event("No new logs available", "2025-08-22T12:00:02Z")
571584

572585

573586
async def generate_normal_log_stream(seq: int):
@@ -644,6 +657,8 @@ async def update_schedule(id_or_name: str, schedule_data: Dict[str, Any]):
644657
schedule = mock_schedules_db[id_or_name]
645658
if "cron" in schedule_data:
646659
schedule["cron"] = schedule_data["cron"]
660+
if "name" in schedule_data:
661+
schedule["name"] = schedule_data["name"]
647662
if "parameters" in schedule_data:
648663
schedule["parameters"] = schedule_data["parameters"]
649664
schedule["updated_at"] = now_iso()

0 commit comments

Comments
 (0)