Coverage for tests/test_api_client.py: 100%
165 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-09 00:25 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-09 00:25 +0000
1from datetime import timedelta 1abcdefg
2from unittest.mock import patch 1abcdefg
4import httpx 1abcdefg
5import pytest 1abcdefg
6import respx 1abcdefg
7from agent_detector import DetectionResult 1abcdefg
8from httpx import Response 1abcdefg
10from fastapi_cloud_cli import __version__ 1abcdefg
11from fastapi_cloud_cli.api import ( 1abcdefg
12 STREAM_LOGS_MAX_RETRIES,
13 APIClient,
14 BuildLogLineMessage,
15 DeploymentStatus,
16 StreamLogError,
17 TooManyRetriesError,
18 get_http_error_code,
19 get_http_error_hint,
20)
21from tests.utils import build_logs_response 1abcdefg
24@pytest.fixture 1abcdefg
25def client() -> httpx.Client: 1abcdefg
26 """Create an HTTP client for testing."""
27 return APIClient() 1abcdefg
30@pytest.fixture 1abcdefg
31def deployment_id() -> str: 1abcdefg
32 return "test-deployment-123" 1abcdefg
35def test_api_client_reports_high_confidence_agent() -> None: 1abcdefg
36 detection = DetectionResult("codex", "high", "environment", "CODEX_THREAD_ID") 2qbzbAbBbrbCbDb
38 with ( 2qbrb
39 patch(
40 "fastapi_cloud_cli.api.client.detect_agent", return_value=detection
41 ) as mock_detect_agent,
42 APIClient() as client,
43 ):
44 assert client.headers["User-Agent"] == ( 2qbzbAbBbrbCbDb
45 f"fastapi-cloud-cli/{__version__} AI-Agent/codex"
46 )
48 mock_detect_agent.assert_called_once_with(minimum_confidence="high") 2qbzbAbBbrbCbDb
51def test_api_client_omits_unattributed_agent() -> None: 1abcdefg
52 with ( 26b7b
53 patch("fastapi_cloud_cli.api.client.detect_agent", return_value=None),
54 APIClient() as client,
55 ):
56 assert client.headers["User-Agent"] == f"fastapi-cloud-cli/{__version__}" 26b!b#b$b7b%b'b
59@pytest.fixture 1abcdefg
60def logs_route(respx_mock: respx.MockRouter, deployment_id: str) -> respx.Route: 1abcdefg
61 return respx_mock.get(f"/deployments/{deployment_id}/build-logs") 1abcdefg
64def test_stream_build_logs_successful( 1abcdefg
65 logs_route: respx.Route,
66 client: APIClient,
67 deployment_id: str,
68) -> None:
69 logs_route.mock( 1vwxyzAB
70 return_value=Response(
71 200,
72 content=build_logs_response(
73 {"type": "message", "message": "Building...", "id": "1"},
74 {"type": "message", "message": "Done!", "id": "2"},
75 {"type": "complete", "id": "3"},
76 ),
77 )
78 )
80 logs = list(client.stream_build_logs(deployment_id)) 1vwxyzAB
82 assert len(logs) == 3 1vwxyzAB
84 assert logs[0].type == "message" 1vwxyzAB
85 assert logs[0].message == "Building..." 1vwxyzAB
87 assert logs[1].type == "message" 1vwxyzAB
88 assert logs[1].message == "Done!" 1vwxyzAB
90 assert logs[2].type == "complete" 1vwxyzAB
93def test_get_http_error_code_returns_network_error() -> None: 1abcdefg
94 assert ( 2(b)b*b+b,b-b.b
95 get_http_error_code(httpx.NetworkError("Connection failed")) == "network_error"
96 )
99def test_get_http_error_hint_for_invalid_deploy_token() -> None: 1abcdefg
100 assert get_http_error_hint("invalid_token", auth_mode="token") == ( 2/b:b;b=b?b@b[b
101 "Make sure FASTAPI_CLOUD_TOKEN contains a valid token."
102 )
105def test_stream_build_logs_failed( 1abcdefg
106 logs_route: respx.Route, client: APIClient, deployment_id: str
107) -> None:
108 logs_route.mock( 1456789!
109 return_value=Response(
110 200,
111 content=build_logs_response(
112 {"type": "message", "message": "Error occurred", "id": "1"},
113 {"type": "failed", "id": "2"},
114 ),
115 )
116 )
118 logs = list(client.stream_build_logs(deployment_id)) 1456789!
120 assert len(logs) == 2 1456789!
121 assert logs[0].type == "message" 1456789!
122 assert logs[1].type == "failed" 1456789!
125@pytest.mark.parametrize("terminal_type", ["complete", "failed"]) 1abcdefg
126def test_stream_build_logs_stop_after_terminal_state( 1abcdefg
127 logs_route: respx.Route,
128 client: APIClient,
129 terminal_type: str,
130 deployment_id: str,
131) -> None:
132 logs_route.mock( 1#$%'()*
133 return_value=Response(
134 200,
135 content=build_logs_response(
136 {"type": "message", "message": "Step 1", "id": "1"},
137 {"type": terminal_type, "id": "2"},
138 {"type": "message", "message": "This should not appear", "id": "3"},
139 ),
140 )
141 )
143 logs = list(client.stream_build_logs(deployment_id)) 1#$%'()*
145 assert len(logs) == 2 1#$%'()*
146 assert logs[0].type == "message" 1#$%'()*
147 assert logs[1].type == terminal_type 1#$%'()*
150def test_stream_build_logs_internal_messages_are_skipped( 1abcdefg
151 logs_route: respx.Route,
152 client: APIClient,
153 deployment_id: str,
154) -> None:
155 logs_route.mock( 1+,-./:;
156 return_value=Response(
157 200,
158 content=build_logs_response(
159 {"type": "heartbeat", "id": "1"},
160 {"type": "message", "message": "Continuing...", "id": "2"},
161 {"type": "complete", "id": "3"},
162 ),
163 )
164 )
166 logs = list(client.stream_build_logs(deployment_id)) 1+,-./:;
168 assert len(logs) == 2 1+,-./:;
169 assert logs[0].type == "message" 1+,-./:;
170 assert logs[1].type == "complete" 1+,-./:;
173def test_stream_build_logs_malformed_json_is_skipped( 1abcdefg
174 logs_route: respx.Route, client: APIClient, deployment_id: str
175) -> None:
176 content = "\n".join( 1EFGHIJK
177 [
178 '{"type": "message", "message": "Valid", "id": "1"}',
179 "not valid json",
180 '{"type": "complete", "id": "2"}',
181 ]
182 )
184 logs_route.mock(return_value=Response(200, content=content)) 1EFGHIJK
186 logs = list(client.stream_build_logs(deployment_id)) 1EFGHIJK
188 assert len(logs) == 2 1EFGHIJK
189 assert logs[0].type == "message" 1EFGHIJK
190 assert logs[1].type == "complete" 1EFGHIJK
193def test_stream_build_logs_unknown_log_type_is_skipped( 1abcdefg
194 logs_route: respx.Route, client: APIClient, deployment_id: str
195) -> None:
196 logs_route.mock( 1=?@[]^_
197 return_value=Response(
198 200,
199 content=build_logs_response(
200 {"type": "unknown_future_type", "id": "1"},
201 {"type": "message", "message": "Valid", "id": "2"},
202 {"type": "complete", "id": "3"},
203 ),
204 )
205 )
207 logs = list(client.stream_build_logs(deployment_id)) 1=?@[]^_
209 # Unknown type should be filtered out
210 assert len(logs) == 2 1=?@[]^_
211 assert logs[0].type == "message" 1=?@[]^_
212 assert logs[1].type == "complete" 1=?@[]^_
215@pytest.mark.parametrize( 1abcdefg
216 "network_error",
217 [httpx.NetworkError, httpx.TimeoutException, httpx.RemoteProtocolError],
218)
219def test_stream_build_logs_network_error_retry( 1abcdefg
220 logs_route: respx.Route,
221 client: APIClient,
222 network_error: Exception,
223 deployment_id: str,
224) -> None:
225 # First call fails, second succeeds
226 logs_route.side_effect = [ 1LMNOPQR
227 network_error,
228 network_error,
229 Response(
230 200,
231 content=build_logs_response(
232 {"type": "message", "message": "Success after retry", "id": "1"},
233 {"type": "complete", "id": "2"},
234 ),
235 ),
236 ]
238 with patch("time.sleep"): 1LMNOPQR
239 logs = list(client.stream_build_logs(deployment_id)) 1LMNOPQR
241 assert len(logs) == 2 1LMNOPQR
242 assert logs[0].type == "message" 1LMNOPQR
243 assert logs[0].message == "Success after retry" 1LMNOPQR
246def test_stream_build_logs_server_error_retry( 1abcdefg
247 logs_route: respx.Route, client: APIClient, deployment_id: str
248) -> None:
249 logs_route.side_effect = [ 2` { | } ~ abbb
250 Response(500, text="Internal Server Error"),
251 Response(
252 200,
253 content=build_logs_response(
254 {"type": "complete", "id": "1"},
255 ),
256 ),
257 ]
259 with patch("time.sleep"): 2` { | } ~ abbb
260 logs = list(client.stream_build_logs(deployment_id)) 2` { | } ~ abbb
262 assert len(logs) == 1 2` { | } ~ abbb
263 assert logs[0].type == "complete" 2` { | } ~ abbb
266def test_stream_build_logs_client_error_raises_immediately( 1abcdefg
267 logs_route: respx.Route, client: APIClient, deployment_id: str
268) -> None:
269 logs_route.mock(return_value=Response(404, text="Not Found")) 2EbFbGbHbIbJbKb
271 with pytest.raises(StreamLogError, match="HTTP 404"): 2EbFbGbHbIbJbKb
272 list(client.stream_build_logs(deployment_id)) 2EbFbGbHbIbJbKb
275def test_stream_build_logs_max_retries_exceeded( 1abcdefg
276 logs_route: respx.Route, client: APIClient, deployment_id: str
277) -> None:
278 logs_route.side_effect = httpx.NetworkError("Connection failed") 2sbtbubvbwbxbyb
280 with patch("time.sleep"): 2sbtbubvbwbxbyb
281 with pytest.raises( 2sbtbubvbwbxbyb
282 TooManyRetriesError,
283 match=f"Failed after {STREAM_LOGS_MAX_RETRIES} attempts",
284 ):
285 list(client.stream_build_logs(deployment_id)) 2sbtbubvbwbxbyb
288def test_stream_build_logs_empty_lines_are_skipped( 1abcdefg
289 logs_route: respx.Route, client: APIClient, deployment_id: str
290) -> None:
291 content = "\n".join( 1STUVWXY
292 [
293 "",
294 '{"type": "message", "message": "Valid", "id": "1"}',
295 " ",
296 '{"type": "complete", "id": "2"}',
297 "",
298 ]
299 )
301 logs_route.mock(return_value=Response(200, content=content)) 1STUVWXY
303 logs = list(client.stream_build_logs(deployment_id)) 1STUVWXY
305 assert len(logs) == 2 1STUVWXY
306 assert logs[0].type == "message" 1STUVWXY
307 assert logs[1].type == "complete" 1STUVWXY
310def test_stream_build_logs_continue_after_timeout( 1abcdefg
311 respx_mock: respx.MockRouter,
312 client: APIClient,
313 deployment_id: str,
314) -> None:
315 for id, last_id in enumerate([None, "1", "2"], start=1): 1hijklmn
316 params = {"last_id": last_id} if last_id else {} 1hijklmn
317 message = f"message {id}" 1hijklmn
319 respx_mock.get( 1hijklmn
320 f"/deployments/{deployment_id}/build-logs", params__eq=params
321 ).mock(
322 return_value=Response(
323 200,
324 content=build_logs_response(
325 {"type": "message", "message": message, "id": str(id)},
326 {"type": "timeout"},
327 ),
328 )
329 )
331 respx_mock.get( 1hijklmn
332 f"/deployments/{deployment_id}/build-logs", params__eq={"last_id": "3"}
333 ).mock(
334 return_value=Response(
335 200,
336 content=build_logs_response(
337 {"type": "message", "message": "message 4", "id": "4"},
338 {"type": "complete", "id": "5"},
339 ),
340 )
341 )
343 logs = client.stream_build_logs(deployment_id) 1hijklmn
345 with patch("time.sleep"): 1hijklmn
346 assert next(logs) == BuildLogLineMessage(message="message 1", id="1") 1hijklmn
347 assert next(logs) == BuildLogLineMessage(message="message 2", id="2") 1hijklmn
348 assert next(logs) == BuildLogLineMessage(message="message 3", id="3") 1hijklmn
349 assert next(logs) == BuildLogLineMessage(message="message 4", id="4") 1hijklmn
350 assert next(logs).type == "complete" 1hijklmn
353def test_stream_build_logs_no_follow_stops_after_timeout( 1abcdefg
354 logs_route: respx.Route, client: APIClient, deployment_id: str
355) -> None:
356 logs_route.mock( 2LbMbNbObPbQbRb
357 return_value=Response(
358 200,
359 content=build_logs_response(
360 {"type": "message", "message": "Step 1", "id": "1"},
361 {"type": "timeout"},
362 ),
363 )
364 )
366 logs = list(client.stream_build_logs(deployment_id, follow=False)) 2LbMbNbObPbQbRb
368 assert logs == [BuildLogLineMessage(message="Step 1", id="1")] 2LbMbNbObPbQbRb
371def test_stream_build_logs_connection_closed_without_complete_failed_or_timeout( 1abcdefg
372 logs_route: respx.Route, client: APIClient, deployment_id: str
373) -> None:
374 logs_route.mock( 2cbdbebfbgbhbib
375 return_value=Response(
376 200,
377 content=build_logs_response(
378 {"type": "message", "message": "hello", "id": "1"},
379 ),
380 )
381 )
383 logs = client.stream_build_logs(deployment_id) 2cbdbebfbgbhbib
385 with patch("time.sleep"), pytest.raises(TooManyRetriesError, match="Failed after"): 2cbdbebfbgbhbib
386 for _ in range(STREAM_LOGS_MAX_RETRIES + 1): 2cbdbebfbgbhbib
387 next(logs) 2cbdbebfbgbhbib
390def test_stream_build_logs_retry_timeout( 1abcdefg
391 logs_route: respx.Route,
392 client: APIClient,
393 deployment_id: str,
394) -> None:
395 clock = [0.0] # Container with a value to use as a result of time.monotonic() mock 1CZ01D23
397 def responses(request: httpx.Request, route: respx.Route) -> Response: 1CZ01D23
398 clock[0] += timedelta(hours=1).total_seconds() # Simulate time passing 1CZ01D23
400 return Response( 1CZ01D23
401 200,
402 content=build_logs_response(
403 {"type": "message", "message": "First", "id": "1"},
404 ),
405 )
407 logs_route.mock(side_effect=responses) 1CZ01D23
409 with ( 1CD
410 patch("time.monotonic", side_effect=lambda: clock[0]),
411 patch("time.sleep"),
412 pytest.raises(TimeoutError, match="timed out"),
413 ):
414 list(client.stream_build_logs(deployment_id)) 1CZ01D23
417@pytest.fixture 1abcdefg
418def poll_route(respx_mock: respx.MockRouter, deployment_id: str) -> respx.Route: 1abcdefg
419 return respx_mock.get(f"/deployments/{deployment_id}") 1abcdefg
422def test_poll_deployment_status_recovers_from_transient_errors( 1abcdefg
423 poll_route: respx.Route, client: APIClient, deployment_id: str
424) -> None:
425 call_count = 0 1opqrstu
427 def handler(request: httpx.Request, route: respx.Route) -> Response: 1opqrstu
428 nonlocal call_count
429 call_count += 1 1opqrstu
430 if call_count <= 2: 1opqrstu
431 return Response(500) 1opqrstu
432 return Response(200, json={"status": "success"}) 1opqrstu
434 poll_route.mock(side_effect=handler) 1opqrstu
436 with patch("time.sleep"): 1opqrstu
437 status = client.poll_deployment_status(deployment_id) 1opqrstu
439 assert status == DeploymentStatus.success 1opqrstu
440 assert call_count == 3 1opqrstu
443def test_poll_deployment_status_keeps_polling_on_unknown_status( 1abcdefg
444 poll_route: respx.Route, client: APIClient, deployment_id: str
445) -> None:
446 poll_route.mock( 2jbkblbmbnbobpb
447 side_effect=[
448 Response(200, json={"status": "some_new_status"}),
449 Response(200, json={"status": "success"}),
450 ]
451 )
453 with patch("time.sleep"): 2jbkblbmbnbobpb
454 status = client.poll_deployment_status(deployment_id) 2jbkblbmbnbobpb
456 assert status == DeploymentStatus.success 2jbkblbmbnbobpb
457 assert poll_route.call_count == 2 2jbkblbmbnbobpb
460def test_poll_deployment_status_raises_after_max_consecutive_errors( 1abcdefg
461 poll_route: respx.Route, client: APIClient, deployment_id: str
462) -> None:
463 poll_route.mock(return_value=Response(500)) 2SbTbUbVbWbXbYb
465 with patch("time.sleep"), pytest.raises(TooManyRetriesError): 2SbTbUbVbWbXbYb
466 client.poll_deployment_status(deployment_id) 2SbTbUbVbWbXbYb
469def test_poll_deployment_status_timeout(client: APIClient, deployment_id: str) -> None: 1abcdefg
470 with ( 28b9b
471 patch("fastapi_cloud_cli.api.client.POLL_TIMEOUT", timedelta(seconds=-1)),
472 pytest.raises(TimeoutError, match="timed out"),
473 ):
474 client.poll_deployment_status(deployment_id) 28b]b^b_b9b`b{b
477def test_get_deployment_with_persisted_failure( 1abcdefg
478 respx_mock: respx.MockRouter,
479 client: APIClient,
480 deployment_id: str,
481) -> None:
482 deployment = { 2Zb0b1b2b3b4b5b
483 "id": deployment_id,
484 "app_id": "123",
485 "slug": "demo-build",
486 "created_at": "2026-09-10T12:00:00Z",
487 "status": "building_image_failed",
488 "url": "https://demo.fastapicloud.app",
489 "dashboard_url": "https://dashboard.fastapicloud.com/demo-build",
490 "failure": {
491 "error_code": "future_build_error",
492 "error_title": "A new build error",
493 "error_message": "Guidance supplied by the backend.",
494 "error_hint": "",
495 },
496 }
497 respx_mock.get(f"/deployments/{deployment_id}").respond(200, json=deployment) 2Zb0b1b2b3b4b5b
499 assert client.get_deployment(deployment_id).model_dump(mode="json") == deployment 2Zb0b1b2b3b4b5b