Skip to content

Commit 9080dbc

Browse files
committed
Add pytest tests for multi-vendor routing logic
Tests verify: - Single vendor stops after first success - Multi-vendor stops after all primaries (even if they fail) - Fallback vendors are not attempted when primaries are configured - Tool-level config overrides category-level config Tests use pytest with fixtures and mocked vendors, can run without API keys in CI/CD. Run with: pytest tests/test_multi_vendor_routing.py -v
1 parent 9ee6674 commit 9080dbc

File tree

3 files changed

+364
-0
lines changed

3 files changed

+364
-0
lines changed

test_multi_vendor_routing.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"""
2+
Behavioral tests for multi-vendor routing logic.
3+
4+
These tests verify the stopping behavior by analyzing the debug output
5+
from the routing system.
6+
"""
7+
8+
import os
9+
import sys
10+
import io
11+
from contextlib import redirect_stdout
12+
from dotenv import load_dotenv
13+
14+
# Load environment variables
15+
load_dotenv()
16+
17+
# Add current directory to path
18+
sys.path.append(os.getcwd())
19+
20+
from tradingagents.dataflows.config import set_config, get_config
21+
22+
23+
def capture_routing_behavior(vendor_config, method, *args):
24+
"""Capture the debug output from route_to_vendor to analyze behavior."""
25+
from tradingagents.dataflows.interface import route_to_vendor
26+
27+
# Set configuration
28+
config = get_config()
29+
config["data_vendors"]["news_data"] = vendor_config
30+
set_config(config)
31+
32+
# Capture stdout
33+
f = io.StringIO()
34+
try:
35+
with redirect_stdout(f):
36+
result = route_to_vendor(method, *args)
37+
output = f.getvalue()
38+
return output, result, None
39+
except Exception as e:
40+
output = f.getvalue()
41+
return output, None, e
42+
43+
44+
def test_single_vendor_stops_after_success():
45+
"""Test that single vendor config stops after first success."""
46+
print("\n" + "="*70)
47+
print("TEST 1: Single Vendor - Should stop after first success")
48+
print("="*70)
49+
50+
output, result, error = capture_routing_behavior(
51+
"alpha_vantage",
52+
"get_news",
53+
"NVDA",
54+
"2024-11-20",
55+
"2024-11-21"
56+
)
57+
58+
# Check that it stopped after primary vendor
59+
assert "Stopping after successful vendor 'alpha_vantage' (single-vendor config)" in output, \
60+
"Should stop after single vendor succeeds"
61+
62+
# Check that fallback vendors were not attempted
63+
assert "Attempting FALLBACK vendor" not in output, \
64+
"Should not attempt fallback vendors when primary succeeds"
65+
66+
# Check vendor attempt count
67+
assert "completed with 1 result(s) from 1 vendor attempt(s)" in output, \
68+
"Should only attempt 1 vendor"
69+
70+
print("✅ PASS: Single vendor stopped after success, no fallbacks attempted")
71+
print(f" Vendor attempts: 1")
72+
73+
74+
def test_multi_vendor_stops_after_all_primaries():
75+
"""Test that multi-vendor config stops after all primary vendors."""
76+
print("\n" + "="*70)
77+
print("TEST 2: Multi-Vendor - Should stop after all primaries")
78+
print("="*70)
79+
80+
output, result, error = capture_routing_behavior(
81+
"alpha_vantage,google",
82+
"get_news",
83+
"NVDA",
84+
"2024-11-20",
85+
"2024-11-21"
86+
)
87+
88+
# Check that both primaries were attempted
89+
assert "Attempting PRIMARY vendor 'alpha_vantage'" in output, \
90+
"Should attempt first primary vendor"
91+
assert "Attempting PRIMARY vendor 'google'" in output, \
92+
"Should attempt second primary vendor"
93+
94+
# Check that it stopped after all primaries
95+
assert "All primary vendors attempted" in output, \
96+
"Should stop after all primary vendors"
97+
98+
# Check that fallback vendors were not attempted
99+
assert "Attempting FALLBACK vendor 'openai'" not in output, \
100+
"Should not attempt fallback vendor (openai)"
101+
assert "Attempting FALLBACK vendor 'local'" not in output, \
102+
"Should not attempt fallback vendor (local)"
103+
104+
print("✅ PASS: Multi-vendor stopped after all primaries, no fallbacks attempted")
105+
print(f" Primary vendors: alpha_vantage, google")
106+
107+
108+
def test_single_vendor_uses_fallback_on_failure():
109+
"""Test that single vendor uses fallback if primary fails."""
110+
print("\n" + "="*70)
111+
print("TEST 3: Single Vendor Failure - Should use fallback")
112+
print("="*70)
113+
114+
# Use a vendor that will likely fail (invalid config)
115+
output, result, error = capture_routing_behavior(
116+
"nonexistent_vendor",
117+
"get_news",
118+
"NVDA",
119+
"2024-11-20",
120+
"2024-11-21"
121+
)
122+
123+
# Check that fallback was attempted
124+
assert "Attempting FALLBACK vendor" in output or "Attempting PRIMARY vendor" in output, \
125+
"Should attempt vendors"
126+
127+
# Should eventually succeed with a fallback
128+
assert result is not None or error is not None, \
129+
"Should either succeed with fallback or fail gracefully"
130+
131+
print("✅ PASS: Fallback mechanism works when primary fails")
132+
133+
134+
def test_debug_output_shows_fallback_order():
135+
"""Test that debug output shows the complete fallback order."""
136+
print("\n" + "="*70)
137+
print("TEST 4: Debug Output - Should show fallback order")
138+
print("="*70)
139+
140+
output, result, error = capture_routing_behavior(
141+
"alpha_vantage",
142+
"get_news",
143+
"NVDA",
144+
"2024-11-20",
145+
"2024-11-21"
146+
)
147+
148+
# Check that fallback order is displayed
149+
assert "Full fallback order:" in output, \
150+
"Should display full fallback order in debug output"
151+
assert "alpha_vantage" in output, \
152+
"Should show primary vendor in fallback order"
153+
154+
print("✅ PASS: Debug output correctly shows fallback order")
155+
156+
157+
if __name__ == '__main__':
158+
print("\n" + "="*70)
159+
print("MULTI-VENDOR ROUTING BEHAVIORAL TESTS")
160+
print("="*70)
161+
162+
try:
163+
test_single_vendor_stops_after_success()
164+
test_multi_vendor_stops_after_all_primaries()
165+
test_single_vendor_uses_fallback_on_failure()
166+
test_debug_output_shows_fallback_order()
167+
168+
print("\n" + "="*70)
169+
print("ALL TESTS PASSED ✅")
170+
print("="*70 + "\n")
171+
172+
except AssertionError as e:
173+
print(f"\n❌ TEST FAILED: {e}\n")
174+
import traceback
175+
traceback.print_exc()
176+
sys.exit(1)
177+
except Exception as e:
178+
print(f"\n❌ ERROR: {e}\n")
179+
import traceback
180+
traceback.print_exc()
181+
sys.exit(1)

tests/__init__.py

Whitespace-only changes.

tests/test_multi_vendor_routing.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""
2+
Unit tests for multi-vendor routing logic.
3+
4+
These tests use mocked vendor implementations and can run without API keys,
5+
making them suitable for CI/CD environments.
6+
7+
Run with: pytest tests/test_multi_vendor_routing.py -v
8+
"""
9+
10+
import pytest
11+
from unittest.mock import patch, MagicMock
12+
import sys
13+
import os
14+
15+
# Add parent directory to path
16+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
17+
18+
19+
@pytest.fixture
20+
def mock_vendors():
21+
"""Create mock vendor functions with __name__ attribute."""
22+
mock_a = MagicMock(return_value="Result from Vendor A")
23+
mock_a.__name__ = "mock_vendor_a"
24+
25+
mock_b = MagicMock(return_value="Result from Vendor B")
26+
mock_b.__name__ = "mock_vendor_b"
27+
28+
mock_c = MagicMock(return_value="Result from Vendor C")
29+
mock_c.__name__ = "mock_vendor_c"
30+
31+
return {'a': mock_a, 'b': mock_b, 'c': mock_c}
32+
33+
34+
@pytest.fixture
35+
def mock_routing(mock_vendors):
36+
"""Set up mocked routing environment."""
37+
with patch('tradingagents.dataflows.interface.VENDOR_METHODS', {
38+
'test_method': {
39+
'vendor_a': mock_vendors['a'],
40+
'vendor_b': mock_vendors['b'],
41+
'vendor_c': mock_vendors['c'],
42+
}
43+
}), \
44+
patch('tradingagents.dataflows.interface.get_category_for_method', return_value='test_category'), \
45+
patch('tradingagents.dataflows.interface.get_config') as mock_config:
46+
yield mock_config, mock_vendors
47+
48+
49+
def test_single_vendor_stops_after_success(mock_routing):
50+
"""Test that single vendor config stops after first successful vendor."""
51+
mock_config, mock_vendors = mock_routing
52+
53+
# Configure single vendor
54+
mock_config.return_value = {
55+
'data_vendors': {'test_category': 'vendor_a'},
56+
'tool_vendors': {}
57+
}
58+
59+
from tradingagents.dataflows.interface import route_to_vendor
60+
61+
result = route_to_vendor('test_method', 'arg1', 'arg2')
62+
63+
# Assertions
64+
mock_vendors['a'].assert_called_once_with('arg1', 'arg2')
65+
mock_vendors['b'].assert_not_called() # Should not try fallback
66+
mock_vendors['c'].assert_not_called() # Should not try fallback
67+
assert result == 'Result from Vendor A'
68+
69+
70+
def test_multi_vendor_stops_after_all_primaries_success(mock_routing):
71+
"""Test that multi-vendor stops after all primaries when they succeed."""
72+
mock_config, mock_vendors = mock_routing
73+
74+
# Configure two primary vendors
75+
mock_config.return_value = {
76+
'data_vendors': {'test_category': 'vendor_a,vendor_b'},
77+
'tool_vendors': {}
78+
}
79+
80+
from tradingagents.dataflows.interface import route_to_vendor
81+
82+
result = route_to_vendor('test_method', 'arg1')
83+
84+
# Assertions
85+
mock_vendors['a'].assert_called_once_with('arg1')
86+
mock_vendors['b'].assert_called_once_with('arg1')
87+
mock_vendors['c'].assert_not_called() # Should NOT try fallback
88+
89+
# Result should contain both
90+
assert 'Result from Vendor A' in result
91+
assert 'Result from Vendor B' in result
92+
93+
94+
def test_multi_vendor_stops_after_all_primaries_failure(mock_routing):
95+
"""Test that multi-vendor stops after all primaries even when they fail."""
96+
mock_config, mock_vendors = mock_routing
97+
98+
# Configure two primary vendors that will fail
99+
mock_vendors['a'].side_effect = Exception("Vendor A failed")
100+
mock_vendors['b'].side_effect = Exception("Vendor B failed")
101+
102+
mock_config.return_value = {
103+
'data_vendors': {'test_category': 'vendor_a,vendor_b'},
104+
'tool_vendors': {}
105+
}
106+
107+
from tradingagents.dataflows.interface import route_to_vendor
108+
109+
# Should raise error after trying all primaries
110+
with pytest.raises(RuntimeError, match="All vendor implementations failed"):
111+
route_to_vendor('test_method', 'arg1')
112+
113+
# Assertions
114+
mock_vendors['a'].assert_called_once_with('arg1')
115+
mock_vendors['b'].assert_called_once_with('arg1')
116+
mock_vendors['c'].assert_not_called() # Should NOT try fallback
117+
118+
119+
def test_multi_vendor_partial_failure_stops_after_primaries(mock_routing):
120+
"""Test that multi-vendor stops after all primaries even if one fails."""
121+
mock_config, mock_vendors = mock_routing
122+
123+
# First vendor fails, second succeeds
124+
mock_vendors['a'].side_effect = Exception("Vendor A failed")
125+
126+
mock_config.return_value = {
127+
'data_vendors': {'test_category': 'vendor_a,vendor_b'},
128+
'tool_vendors': {}
129+
}
130+
131+
from tradingagents.dataflows.interface import route_to_vendor
132+
133+
result = route_to_vendor('test_method', 'arg1')
134+
135+
# Assertions
136+
mock_vendors['a'].assert_called_once_with('arg1')
137+
mock_vendors['b'].assert_called_once_with('arg1')
138+
mock_vendors['c'].assert_not_called() # Should NOT try fallback
139+
140+
assert result == 'Result from Vendor B'
141+
142+
143+
def test_single_vendor_uses_fallback_on_failure(mock_routing):
144+
"""Test that single vendor uses fallback if primary fails."""
145+
mock_config, mock_vendors = mock_routing
146+
147+
# Primary vendor fails
148+
mock_vendors['a'].side_effect = Exception("Vendor A failed")
149+
150+
mock_config.return_value = {
151+
'data_vendors': {'test_category': 'vendor_a'},
152+
'tool_vendors': {}
153+
}
154+
155+
from tradingagents.dataflows.interface import route_to_vendor
156+
157+
result = route_to_vendor('test_method', 'arg1')
158+
159+
# Assertions
160+
mock_vendors['a'].assert_called_once_with('arg1')
161+
mock_vendors['b'].assert_called_once_with('arg1') # Should try fallback
162+
assert result == 'Result from Vendor B'
163+
164+
165+
def test_tool_level_override_takes_precedence(mock_routing):
166+
"""Test that tool-level vendor config overrides category-level."""
167+
mock_config, mock_vendors = mock_routing
168+
169+
# Category says vendor_a, but tool override says vendor_b
170+
mock_config.return_value = {
171+
'data_vendors': {'test_category': 'vendor_a'},
172+
'tool_vendors': {'test_method': 'vendor_b'}
173+
}
174+
175+
from tradingagents.dataflows.interface import route_to_vendor
176+
177+
result = route_to_vendor('test_method', 'arg1')
178+
179+
# Assertions
180+
mock_vendors['a'].assert_not_called() # Category default ignored
181+
mock_vendors['b'].assert_called_once_with('arg1') # Tool override used
182+
assert result == 'Result from Vendor B'
183+

0 commit comments

Comments
 (0)