Skip to content

Commit 4e0c085

Browse files
committed
Add polling tests
1 parent 145f66c commit 4e0c085

1 file changed

Lines changed: 270 additions & 0 deletions

File tree

test_polling.py

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import pytest
2+
from unittest.mock import Mock, patch
3+
from typing import Any
4+
5+
from src.runloop_api_client.lib.polling import poll_until, PollingConfig, PollingTimeout
6+
7+
8+
class TestPollingConfig:
9+
"""Test PollingConfig dataclass"""
10+
11+
def test_default_config(self):
12+
config = PollingConfig()
13+
assert config.interval_seconds == 1.0
14+
assert config.max_attempts == 120
15+
assert config.timeout_seconds is None
16+
17+
def test_custom_config(self):
18+
config = PollingConfig(
19+
interval_seconds=0.5,
20+
max_attempts=10,
21+
timeout_seconds=30.0
22+
)
23+
assert config.interval_seconds == 0.5
24+
assert config.max_attempts == 10
25+
assert config.timeout_seconds == 30.0
26+
27+
28+
class TestPollingTimeout:
29+
"""Test PollingTimeout exception"""
30+
31+
def test_polling_timeout_initialization(self):
32+
last_value = {"status": "running"}
33+
exception = PollingTimeout("Test message", last_value)
34+
35+
assert exception.last_value == last_value
36+
assert "Test message" in str(exception)
37+
assert "Last retrieved value: {'status': 'running'}" in str(exception)
38+
39+
40+
class TestPollUntil:
41+
"""Test poll_until function"""
42+
43+
def test_immediate_success(self):
44+
"""Test when condition is met on first attempt"""
45+
retriever = Mock(return_value="completed")
46+
is_terminal = Mock(return_value=True)
47+
48+
result = poll_until(retriever, is_terminal)
49+
50+
assert result == "completed"
51+
assert retriever.call_count == 1
52+
assert is_terminal.call_count == 1
53+
is_terminal.assert_called_with("completed")
54+
55+
def test_success_after_multiple_attempts(self):
56+
"""Test when condition is met after several attempts"""
57+
values = ["pending", "running", "completed"]
58+
retriever = Mock(side_effect=values)
59+
is_terminal = Mock(side_effect=[False, False, True])
60+
61+
with patch('time.sleep') as mock_sleep:
62+
result = poll_until(retriever, is_terminal)
63+
64+
assert result == "completed"
65+
assert retriever.call_count == 3
66+
assert is_terminal.call_count == 3
67+
assert mock_sleep.call_count == 2 # Should sleep between attempts
68+
69+
def test_custom_config_interval(self):
70+
"""Test with custom polling interval"""
71+
retriever = Mock(side_effect=["pending", "completed"])
72+
is_terminal = Mock(side_effect=[False, True])
73+
config = PollingConfig(interval_seconds=0.1)
74+
75+
with patch('time.sleep') as mock_sleep:
76+
result = poll_until(retriever, is_terminal, config)
77+
78+
assert result == "completed"
79+
mock_sleep.assert_called_with(0.1)
80+
81+
def test_max_attempts_exceeded(self):
82+
"""Test when max attempts is exceeded"""
83+
retriever = Mock(return_value="still_running")
84+
is_terminal = Mock(return_value=False)
85+
config = PollingConfig(max_attempts=3, interval_seconds=0.01)
86+
87+
with patch('time.sleep'):
88+
with pytest.raises(PollingTimeout) as exc_info:
89+
poll_until(retriever, is_terminal, config)
90+
91+
assert "Exceeded maximum attempts (3)" in str(exc_info.value)
92+
assert exc_info.value.last_value == "still_running"
93+
assert retriever.call_count == 3
94+
95+
def test_timeout_exceeded(self):
96+
"""Test when timeout is exceeded"""
97+
retriever = Mock(return_value="still_running")
98+
is_terminal = Mock(return_value=False)
99+
config = PollingConfig(timeout_seconds=0.1, interval_seconds=0.01)
100+
101+
# Mock time.time to simulate timeout
102+
start_time = 1000.0
103+
with patch('time.time', side_effect=[start_time, start_time + 0.05, start_time + 0.15]):
104+
with patch('time.sleep'):
105+
with pytest.raises(PollingTimeout) as exc_info:
106+
poll_until(retriever, is_terminal, config)
107+
108+
assert "Exceeded timeout of 0.1 seconds" in str(exc_info.value)
109+
assert exc_info.value.last_value == "still_running"
110+
111+
def test_error_without_handler(self):
112+
"""Test that exceptions are re-raised when no error handler is provided"""
113+
retriever = Mock(side_effect=ValueError("Test error"))
114+
is_terminal = Mock(return_value=False)
115+
116+
with pytest.raises(ValueError, match="Test error"):
117+
poll_until(retriever, is_terminal)
118+
119+
def test_error_with_handler_continue(self):
120+
"""Test error handler that allows polling to continue"""
121+
retriever = Mock(side_effect=[ValueError("Test error"), "recovered"])
122+
is_terminal = Mock(side_effect=[False, True])
123+
124+
def error_handler(e: Exception) -> str:
125+
return "error_handled"
126+
127+
with patch('time.sleep'):
128+
result = poll_until(retriever, is_terminal, on_error=error_handler)
129+
130+
assert result == "recovered"
131+
assert retriever.call_count == 2
132+
assert is_terminal.call_count == 2
133+
134+
def test_error_with_handler_reraise(self):
135+
"""Test error handler that re-raises the exception"""
136+
retriever = Mock(side_effect=ValueError("Test error"))
137+
is_terminal = Mock(return_value=False)
138+
139+
def error_handler(e: Exception) -> None:
140+
raise e
141+
142+
with pytest.raises(ValueError, match="Test error"):
143+
poll_until(retriever, is_terminal, on_error=error_handler)
144+
145+
def test_error_handler_return_terminal_value(self):
146+
"""Test error handler that returns a terminal value"""
147+
retriever = Mock(side_effect=ValueError("Test error"))
148+
is_terminal = Mock(side_effect=[True]) # Terminal condition met on error handler return
149+
150+
def error_handler(e: Exception) -> str:
151+
return "error_terminal"
152+
153+
result = poll_until(retriever, is_terminal, on_error=error_handler)
154+
155+
assert result == "error_terminal"
156+
assert retriever.call_count == 1
157+
assert is_terminal.call_count == 1
158+
159+
def test_multiple_errors_with_handler(self):
160+
"""Test multiple errors with handler"""
161+
retriever = Mock(side_effect=[
162+
ValueError("Error 1"),
163+
RuntimeError("Error 2"),
164+
"success"
165+
])
166+
is_terminal = Mock(side_effect=[False, False, True])
167+
168+
error_count = 0
169+
def error_handler(e: Exception) -> str:
170+
nonlocal error_count
171+
error_count += 1
172+
return f"handled_error_{error_count}"
173+
174+
with patch('time.sleep'):
175+
result = poll_until(retriever, is_terminal, on_error=error_handler)
176+
177+
assert result == "success"
178+
assert error_count == 2
179+
assert retriever.call_count == 3
180+
181+
def test_none_values_handling(self):
182+
"""Test handling of None values"""
183+
retriever = Mock(side_effect=[None, None, "final"])
184+
is_terminal = Mock(side_effect=[False, False, True])
185+
186+
with patch('time.sleep'):
187+
result = poll_until(retriever, is_terminal)
188+
189+
assert result == "final"
190+
assert retriever.call_count == 3
191+
192+
def test_complex_object_polling(self):
193+
"""Test polling with complex objects"""
194+
class Status:
195+
def __init__(self, state: str, progress: int):
196+
self.state = state
197+
self.progress = progress
198+
199+
statuses = [
200+
Status("starting", 0),
201+
Status("running", 50),
202+
Status("completed", 100)
203+
]
204+
205+
retriever = Mock(side_effect=statuses)
206+
is_terminal = Mock(side_effect=[False, False, True])
207+
208+
with patch('time.sleep'):
209+
result = poll_until(retriever, is_terminal)
210+
211+
assert result.state == "completed"
212+
assert result.progress == 100
213+
214+
def test_zero_max_attempts(self):
215+
"""Test with zero max attempts"""
216+
retriever = Mock(return_value="value")
217+
is_terminal = Mock(return_value=False)
218+
config = PollingConfig(max_attempts=0)
219+
220+
with pytest.raises(PollingTimeout) as exc_info:
221+
poll_until(retriever, is_terminal, config)
222+
223+
assert "Exceeded maximum attempts (0)" in str(exc_info.value)
224+
assert retriever.call_count == 1 # Retriever is called once, then attempts check happens
225+
226+
def test_negative_interval(self):
227+
"""Test with negative interval (should still work)"""
228+
retriever = Mock(side_effect=["first", "second"])
229+
is_terminal = Mock(side_effect=[False, True])
230+
config = PollingConfig(interval_seconds=-0.1)
231+
232+
with patch('time.sleep') as mock_sleep:
233+
result = poll_until(retriever, is_terminal, config)
234+
235+
assert result == "second"
236+
mock_sleep.assert_called_with(-0.1)
237+
238+
def test_both_timeout_and_max_attempts(self):
239+
"""Test when both timeout and max_attempts are set"""
240+
retriever = Mock(return_value="still_running")
241+
is_terminal = Mock(return_value=False)
242+
config = PollingConfig(max_attempts=5, timeout_seconds=0.1, interval_seconds=0.01)
243+
244+
# Mock time to hit timeout before max_attempts
245+
start_time = 1000.0
246+
with patch('time.time', side_effect=[start_time, start_time + 0.05, start_time + 0.15]):
247+
with patch('time.sleep'):
248+
with pytest.raises(PollingTimeout) as exc_info:
249+
poll_until(retriever, is_terminal, config)
250+
251+
# Should hit timeout first
252+
assert "Exceeded timeout of 0.1 seconds" in str(exc_info.value)
253+
assert retriever.call_count == 2 # Called twice before timeout
254+
255+
def test_terminal_condition_changes(self):
256+
"""Test when terminal condition logic changes during polling"""
257+
retriever = Mock(side_effect=["value1", "value2", "value3"])
258+
259+
call_count = 0
260+
def dynamic_terminal(value: Any) -> bool:
261+
nonlocal call_count
262+
call_count += 1
263+
# First two calls return False, third returns True
264+
return call_count >= 3
265+
266+
with patch('time.sleep'):
267+
result = poll_until(retriever, dynamic_terminal)
268+
269+
assert result == "value3"
270+
assert retriever.call_count == 3

0 commit comments

Comments
 (0)