Coverage for tests/test_cli_deployments.py: 100%
313 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
1import json 1abcdefg
2from datetime import datetime, timezone 1abcdefg
3from typing import Any 1abcdefg
4from unittest.mock import patch 1abcdefg
6import httpx 1abcdefg
7import pytest 1abcdefg
8import respx 1abcdefg
9import time_machine 1abcdefg
10from httpx import Response 1abcdefg
11from inline_snapshot import snapshot 1abcdefg
13from fastapi_cloud_cli.api import StreamLogError, TooManyRetriesError 1abcdefg
14from fastapi_cloud_cli.cli import cloud_app as app 1abcdefg
15from tests.conftest import ConfiguredApp 1abcdefg
16from tests.utils import SnapshotCliRunner, build_logs_response, changing_dir 1abcdefg
18runner = SnapshotCliRunner() 1abcdefg
21@pytest.fixture 1abcdefg
22def build_logs_deployment(respx_mock: respx.MockRouter) -> dict[str, Any]: 1abcdefg
23 deployment: dict[str, Any] = { 1abcdefg
24 "id": "00000000-0000-4000-8000-000000000003",
25 "app_id": "00000000-0000-4000-8000-000000000002",
26 "slug": "api-build",
27 "status": "building_image",
28 "created_at": "2026-05-22T10:00:00Z",
29 "failure": None,
30 }
31 respx_mock.get(f"/deployments/{deployment['id']}").mock( 1abcdefg
32 side_effect=lambda request: Response(200, json=deployment)
33 )
34 return deployment 1abcdefg
37@pytest.mark.respx 1abcdefg
38def test_lists_deployments_as_json_with_app_id_and_pagination_params( 1abcdefg
39 logged_in_cli: None,
40 respx_mock: respx.MockRouter,
41) -> None:
42 app_id = "00000000-0000-4000-8000-000000000002" 1QRSTUVW
43 deployment: dict[str, Any] = { 1QRSTUVW
44 "id": "00000000-0000-4000-8000-000000000003",
45 "app_id": app_id,
46 "slug": "api-20260522",
47 "status": "success",
48 "failure": None,
49 "created_at": "2026-05-22T10:00:00Z",
50 "url": "https://api.fastapicloud.app",
51 "dashboard_url": "https://dashboard.fastapicloud.com/acme/apps/api/deployments/api-20260522",
52 }
53 respx_mock.get( 1QRSTUVW
54 f"/apps/{app_id}/deployments/",
55 params={"limit": "100", "skip": "20"},
56 ).mock(return_value=Response(200, json={"data": [deployment], "count": 1}))
58 result = runner.invoke( 1QRSTUVW
59 app,
60 [
61 "deployments",
62 "list",
63 "--app-id",
64 app_id,
65 "--limit",
66 "100",
67 "--offset",
68 "20",
69 "--json",
70 ],
71 )
73 assert result.exit_code == 0 1QRSTUVW
74 assert json.loads(result.stdout) == { 1QRSTUVW
75 "data": {
76 "deployments": [deployment],
77 "total_count": 1,
78 "limit": 100,
79 "offset": 20,
80 }
81 }
82 assert result.stderr == "" 1QRSTUVW
85@pytest.mark.respx 1abcdefg
86def test_lists_deployments_as_json_uses_linked_app( 1abcdefg
87 logged_in_cli: None,
88 respx_mock: respx.MockRouter,
89 configured_app: ConfiguredApp,
90) -> None:
91 deployment: dict[str, Any] = { 1XYZ0123
92 "id": "00000000-0000-4000-8000-000000000003",
93 "app_id": configured_app.app_id,
94 "slug": "api-20260522",
95 "status": "success",
96 "failure": None,
97 "created_at": "2026-05-22T10:00:00Z",
98 "url": "https://api.fastapicloud.app",
99 "dashboard_url": "https://dashboard.fastapicloud.com/acme/apps/api/deployments/api-20260522",
100 }
101 respx_mock.get( 1XYZ0123
102 f"/apps/{configured_app.app_id}/deployments/",
103 params={"limit": "100", "skip": "0"},
104 ).mock(return_value=Response(200, json={"data": [deployment], "count": 1}))
106 with changing_dir(configured_app.path): 1XYZ0123
107 result = runner.invoke(app, ["deployments", "list", "--json"]) 1XYZ0123
109 assert result.exit_code == 0 1XYZ0123
110 assert json.loads(result.stdout) == { 1XYZ0123
111 "data": {
112 "deployments": [deployment],
113 "total_count": 1,
114 "limit": 100,
115 "offset": 0,
116 }
117 }
118 assert result.stderr == "" 1XYZ0123
121@pytest.mark.respx 1abcdefg
122@time_machine.travel(datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc), tick=False) 1abcdefg
123def test_lists_deployments_human_output_shows_id_status_and_created( 1abcdefg
124 logged_in_cli: None,
125 respx_mock: respx.MockRouter,
126) -> None:
127 app_id = "00000000-0000-4000-8000-000000000002" 2` { | } ~ abbb
128 deployment: dict[str, Any] = { 2` { | } ~ abbb
129 "id": "00000000-0000-4000-8000-000000000003",
130 "app_id": app_id,
131 "slug": "api-20260522",
132 "status": "success",
133 "failure": None,
134 "created_at": "2026-05-22T10:00:00Z",
135 "url": "https://api.fastapicloud.app",
136 "dashboard_url": "https://dashboard.fastapicloud.com/acme/apps/api/deployments/api-20260522",
137 }
138 respx_mock.get( 2` { | } ~ abbb
139 f"/apps/{app_id}/deployments/",
140 params={"limit": "100", "skip": "0"},
141 ).mock(return_value=Response(200, json={"data": [deployment], "count": 1}))
143 result = runner.invoke(app, ["deployments", "list", "--app-id", app_id]) 2` { | } ~ abbb
145 assert result.exit_code == 0 2` { | } ~ abbb
146 assert result.output == snapshot("""\ 2` { | } ~ abbb
147deployments
149ID Status Created
15100000000-0000-4000-8000-000000000003 success 2 hours ago\
152""")
155@pytest.mark.respx 1abcdefg
156def test_lists_deployments_in_human_output_empty( 1abcdefg
157 logged_in_cli: None,
158 respx_mock: respx.MockRouter,
159) -> None:
160 app_id = "00000000-0000-4000-8000-000000000002" 2|b}b~bacbcccdc
161 respx_mock.get( 2|b}b~bacbcccdc
162 f"/apps/{app_id}/deployments/",
163 params={"limit": "100", "skip": "0"},
164 ).mock(return_value=Response(200, json={"data": [], "count": 0}))
166 result = runner.invoke(app, ["deployments", "list", "--app-id", app_id]) 2|b}b~bacbcccdc
168 assert result.exit_code == 0 2|b}b~bacbcccdc
169 assert result.output == snapshot("""\ 2|b}b~bacbcccdc
170deployments
172No deployments found.\
173""")
176def test_lists_deployments_json_returns_not_logged_in_when_logged_out( 1abcdefg
177 logged_out_cli: None,
178) -> None:
179 result = runner.invoke( 2NcOcPcQcRcScTc
180 app,
181 [
182 "deployments",
183 "list",
184 "--app-id",
185 "00000000-0000-4000-8000-000000000002",
186 "--json",
187 ],
188 )
190 assert result.exit_code == 1 2NcOcPcQcRcScTc
191 assert json.loads(result.stdout) == { 2NcOcPcQcRcScTc
192 "error": {
193 "code": "not_logged_in",
194 "message": "No credentials found.",
195 "hint": "Run `fastapi cloud login`.",
196 }
197 }
198 assert result.stderr == "" 2NcOcPcQcRcScTc
201def test_lists_deployments_json_returns_missing_required_input_without_app_context( 1abcdefg
202 logged_in_cli: None,
203) -> None:
204 result = runner.invoke(app, ["deployments", "list", "--json"]) 2UcVcWcXcYcZc0c
206 assert result.exit_code == 1 2UcVcWcXcYcZc0c
207 assert json.loads(result.stdout) == { 2UcVcWcXcYcZc0c
208 "error": {
209 "code": "missing_required_input",
210 "message": "App ID is required.",
211 "hint": "Pass --app-id or run `fastapi cloud apps create --link` first.",
212 }
213 }
214 assert result.stderr == "" 2UcVcWcXcYcZc0c
217def test_gets_deployment_json_returns_not_logged_in_when_logged_out( 1abcdefg
218 logged_out_cli: None,
219) -> None:
220 result = runner.invoke( 21c2c3c4c5c6c7c
221 app,
222 [
223 "deployments",
224 "get",
225 "00000000-0000-4000-8000-000000000003",
226 "--app-id",
227 "00000000-0000-4000-8000-000000000002",
228 "--json",
229 ],
230 )
232 assert result.exit_code == 1 21c2c3c4c5c6c7c
233 assert json.loads(result.stdout) == { 21c2c3c4c5c6c7c
234 "error": {
235 "code": "not_logged_in",
236 "message": "No credentials found.",
237 "hint": "Run `fastapi cloud login`.",
238 }
239 }
240 assert result.stderr == "" 21c2c3c4c5c6c7c
243@pytest.mark.respx 1abcdefg
244def test_gets_deployment_as_json_with_app_id( 1abcdefg
245 logged_in_cli: None,
246 respx_mock: respx.MockRouter,
247) -> None:
248 app_id = "00000000-0000-4000-8000-000000000002" 1456789!
249 deployment: dict[str, Any] = { 1456789!
250 "id": "00000000-0000-4000-8000-000000000003",
251 "app_id": app_id,
252 "slug": "api-20260522",
253 "status": "success",
254 "failure": None,
255 "created_at": "2026-05-22T10:00:00Z",
256 "url": "https://api.fastapicloud.app",
257 "dashboard_url": "https://dashboard.fastapicloud.com/acme/apps/api/deployments/api-20260522",
258 }
259 respx_mock.get(f"/deployments/{deployment['id']}").mock( 1456789!
260 return_value=Response(200, json=deployment)
261 )
263 result = runner.invoke( 1456789!
264 app,
265 [
266 "deployments",
267 "get",
268 deployment["id"],
269 "--app-id",
270 app_id,
271 "--json",
272 ],
273 )
275 assert result.exit_code == 0 1456789!
276 assert json.loads(result.stdout) == {"data": {"deployment": deployment}} 1456789!
277 assert result.stderr == "" 1456789!
280@pytest.mark.respx 1abcdefg
281def test_gets_deployment_as_json_uses_linked_app( 1abcdefg
282 logged_in_cli: None,
283 respx_mock: respx.MockRouter,
284 configured_app: ConfiguredApp,
285) -> None:
286 deployment: dict[str, Any] = { 1#$%'()*
287 "id": "00000000-0000-4000-8000-000000000003",
288 "app_id": configured_app.app_id,
289 "slug": "api-20260522",
290 "status": "success",
291 "failure": None,
292 "created_at": "2026-05-22T10:00:00Z",
293 "url": "https://api.fastapicloud.app",
294 "dashboard_url": "https://dashboard.fastapicloud.com/acme/apps/api/deployments/api-20260522",
295 }
296 respx_mock.get(f"/deployments/{deployment['id']}").mock( 1#$%'()*
297 return_value=Response(200, json=deployment)
298 )
300 with changing_dir(configured_app.path): 1#$%'()*
301 result = runner.invoke(app, ["deployments", "get", deployment["id"], "--json"]) 1#$%'()*
303 assert result.exit_code == 0 1#$%'()*
304 assert json.loads(result.stdout) == {"data": {"deployment": deployment}} 1#$%'()*
305 assert result.stderr == "" 1#$%'()*
308def test_gets_deployment_json_returns_missing_required_input_without_app_context( 1abcdefg
309 logged_in_cli: None,
310) -> None:
311 result = runner.invoke( 28c9c!c#c$c%c'c
312 app,
313 ["deployments", "get", "00000000-0000-4000-8000-000000000003", "--json"],
314 )
316 assert result.exit_code == 1 28c9c!c#c$c%c'c
317 assert json.loads(result.stdout) == { 28c9c!c#c$c%c'c
318 "error": {
319 "code": "missing_required_input",
320 "message": "App ID is required.",
321 "hint": "Pass --app-id or run `fastapi cloud apps create --link` first.",
322 }
323 }
324 assert result.stderr == "" 28c9c!c#c$c%c'c
327@pytest.mark.respx 1abcdefg
328@time_machine.travel(datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc), tick=False) 1abcdefg
329def test_gets_deployment_in_human_output( 1abcdefg
330 logged_in_cli: None,
331 respx_mock: respx.MockRouter,
332) -> None:
333 app_id = "00000000-0000-4000-8000-000000000002" 2cbdbebfbgbhbib
334 deployment: dict[str, Any] = { 2cbdbebfbgbhbib
335 "id": "00000000-0000-4000-8000-000000000003",
336 "app_id": app_id,
337 "slug": "api-20260522",
338 "status": "success",
339 "failure": None,
340 "created_at": "2026-05-22T10:00:00Z",
341 "url": "https://api.fastapicloud.app",
342 "dashboard_url": "https://dashboard.example.com/d/api-20260522",
343 }
344 respx_mock.get(f"/deployments/{deployment['id']}").mock( 2cbdbebfbgbhbib
345 return_value=Response(200, json=deployment)
346 )
348 result = runner.invoke( 2cbdbebfbgbhbib
349 app,
350 ["deployments", "get", deployment["id"], "--app-id", app_id],
351 )
353 assert result.exit_code == 0 2cbdbebfbgbhbib
354 assert result.output == snapshot("""\ 2cbdbebfbgbhbib
355deployment
357🚀 00000000-0000-4000-8000-000000000003
359 app id 00000000-0000-4000-8000-000000000002
360 slug api-20260522
361 status success
362 created 2 hours ago
363 url https://api.fastapicloud.app
364 dashboard https://dashboard.example.com/d/api-20260522\
365""")
368@pytest.mark.respx 1abcdefg
369def test_gets_build_logs_no_follow_as_json( 1abcdefg
370 logged_in_cli: None,
371 build_logs_deployment: dict[str, Any],
372 respx_mock: respx.MockRouter,
373) -> None:
374 deployment_id = "00000000-0000-4000-8000-000000000003" 2jbkblbmbnbobpb
375 respx_mock.get(f"/deployments/{deployment_id}/build-logs").mock( 2jbkblbmbnbobpb
376 return_value=Response(
377 200,
378 content=build_logs_response(
379 {
380 "type": "message",
381 "id": "1748682000000-0",
382 "message": "Installing dependencies",
383 },
384 {
385 "type": "message",
386 "id": "1748682000001-0",
387 "message": "Building image",
388 },
389 ),
390 )
391 )
393 result = runner.invoke( 2jbkblbmbnbobpb
394 app,
395 ["deployments", "build-logs", deployment_id, "--no-follow", "--json"],
396 )
398 assert result.exit_code == 0 2jbkblbmbnbobpb
399 assert json.loads(result.stdout) == { 2jbkblbmbnbobpb
400 "data": {
401 "deployment_id": deployment_id,
402 "failure": None,
403 "failed": False,
404 "logs": [
405 {
406 "id": "1748682000000-0",
407 "message": "Installing dependencies",
408 },
409 {
410 "id": "1748682000001-0",
411 "message": "Building image",
412 },
413 ],
414 }
415 }
416 assert result.stderr == "" 2jbkblbmbnbobpb
419@pytest.mark.respx 1abcdefg
420def test_gets_failed_build_logs_no_follow_as_json_exits_nonzero( 1abcdefg
421 logged_in_cli: None,
422 build_logs_deployment: dict[str, Any],
423 respx_mock: respx.MockRouter,
424) -> None:
425 deployment_id = "00000000-0000-4000-8000-000000000003" 2qbrbsbtbubvbwb
426 respx_mock.get(f"/deployments/{deployment_id}/build-logs").mock( 2qbrbsbtbubvbwb
427 return_value=Response(
428 200,
429 content=build_logs_response(
430 {
431 "type": "message",
432 "id": "1748682000000-0",
433 "message": "Installing dependencies",
434 },
435 {"type": "failed", "id": "1748682000001-0"},
436 ),
437 )
438 )
440 result = runner.invoke( 2qbrbsbtbubvbwb
441 app,
442 ["deployments", "build-logs", deployment_id, "--no-follow", "--json"],
443 )
445 assert result.exit_code == 1 2qbrbsbtbubvbwb
446 assert json.loads(result.stdout) == { 2qbrbsbtbubvbwb
447 "data": {
448 "deployment_id": deployment_id,
449 "failure": None,
450 "failed": True,
451 "logs": [
452 {
453 "id": "1748682000000-0",
454 "message": "Installing dependencies",
455 },
456 ],
457 }
458 }
459 assert result.stderr == "" 2qbrbsbtbubvbwb
462@pytest.mark.respx 1abcdefg
463def test_gets_build_logs_no_follow_in_human_output( 1abcdefg
464 logged_in_cli: None,
465 build_logs_deployment: dict[str, Any],
466 respx_mock: respx.MockRouter,
467) -> None:
468 deployment_id = "00000000-0000-4000-8000-000000000003" 2xbybzbAbBbCbDb
469 respx_mock.get(f"/deployments/{deployment_id}/build-logs").mock( 2xbybzbAbBbCbDb
470 return_value=Response(
471 200,
472 content=build_logs_response(
473 {
474 "type": "message",
475 "id": "1748682000000-0",
476 "message": "Installing dependencies",
477 },
478 {"type": "complete", "id": "1748682000001-0"},
479 ),
480 )
481 )
483 result = runner.invoke( 2xbybzbAbBbCbDb
484 app,
485 ["deployments", "build-logs", deployment_id, "--no-follow"],
486 )
488 assert result.exit_code == 0 2xbybzbAbBbCbDb
489 assert "Fetching build logs" in result.output 2xbybzbAbBbCbDb
490 assert "Installing dependencies" in result.output 2xbybzbAbBbCbDb
493@pytest.mark.respx 1abcdefg
494def test_gets_build_logs_no_follow_in_human_output_empty( 1abcdefg
495 logged_in_cli: None,
496 build_logs_deployment: dict[str, Any],
497 respx_mock: respx.MockRouter,
498) -> None:
499 deployment_id = "00000000-0000-4000-8000-000000000003" 2ecfcgchcicjckc
500 respx_mock.get(f"/deployments/{deployment_id}/build-logs").mock( 2ecfcgchcicjckc
501 return_value=Response(200, content="")
502 )
504 result = runner.invoke( 2ecfcgchcicjckc
505 app,
506 ["deployments", "build-logs", deployment_id, "--no-follow"],
507 )
509 assert result.exit_code == 0 2ecfcgchcicjckc
510 assert "No build logs found." in result.output 2ecfcgchcicjckc
513@pytest.mark.respx 1abcdefg
514def test_gets_failed_build_logs_no_follow_in_human_output_exits_nonzero( 1abcdefg
515 logged_in_cli: None,
516 build_logs_deployment: dict[str, Any],
517 respx_mock: respx.MockRouter,
518) -> None:
519 deployment_id = "00000000-0000-4000-8000-000000000003" 2EbFbGbHbIbJbKb
520 respx_mock.get(f"/deployments/{deployment_id}/build-logs").mock( 2EbFbGbHbIbJbKb
521 return_value=Response(
522 200,
523 content=build_logs_response(
524 {
525 "type": "message",
526 "id": "1748682000000-0",
527 "message": "Installing dependencies",
528 },
529 {"type": "failed", "id": "1748682000001-0"},
530 ),
531 )
532 )
534 result = runner.invoke( 2EbFbGbHbIbJbKb
535 app,
536 ["deployments", "build-logs", deployment_id, "--no-follow"],
537 )
539 assert result.exit_code == 1 2EbFbGbHbIbJbKb
540 assert "Installing dependencies" in result.output 2EbFbGbHbIbJbKb
541 assert "Build failed." in result.output 2EbFbGbHbIbJbKb
544@pytest.mark.respx 1abcdefg
545def test_streams_build_logs_in_human_output( 1abcdefg
546 logged_in_cli: None,
547 build_logs_deployment: dict[str, Any],
548 respx_mock: respx.MockRouter,
549) -> None:
550 deployment_id = "00000000-0000-4000-8000-000000000003" 2LbMbNbObPbQbRb
551 respx_mock.get(f"/deployments/{deployment_id}/build-logs").mock( 2LbMbNbObPbQbRb
552 return_value=Response(
553 200,
554 content=build_logs_response(
555 {
556 "type": "message",
557 "id": "1748682000000-0",
558 "message": "Installing dependencies",
559 },
560 {"type": "complete", "id": "1748682000001-0"},
561 ),
562 )
563 )
565 result = runner.invoke( 2LbMbNbObPbQbRb
566 app,
567 ["deployments", "build-logs", deployment_id],
568 )
570 assert result.exit_code == 0 2LbMbNbObPbQbRb
571 assert "Streaming build logs" in result.output 2LbMbNbObPbQbRb
572 assert "Installing dependencies" in result.output 2LbMbNbObPbQbRb
575@pytest.mark.respx 1abcdefg
576def test_streams_failed_build_logs_in_human_output_exits_nonzero( 1abcdefg
577 logged_in_cli: None,
578 build_logs_deployment: dict[str, Any],
579 respx_mock: respx.MockRouter,
580) -> None:
581 deployment_id = "00000000-0000-4000-8000-000000000003" 2SbTbUbVbWbXbYb
582 respx_mock.get(f"/deployments/{deployment_id}/build-logs").mock( 2SbTbUbVbWbXbYb
583 return_value=Response(
584 200,
585 content=build_logs_response(
586 {
587 "type": "message",
588 "id": "1748682000000-0",
589 "message": "Installing dependencies",
590 },
591 {"type": "failed", "id": "1748682000001-0"},
592 ),
593 )
594 )
596 result = runner.invoke( 2SbTbUbVbWbXbYb
597 app,
598 ["deployments", "build-logs", deployment_id],
599 )
601 assert result.exit_code == 1 2SbTbUbVbWbXbYb
602 assert "Installing dependencies" in result.output 2SbTbUbVbWbXbYb
603 assert "Build failed." in result.output 2SbTbUbVbWbXbYb
606@pytest.mark.respx 1abcdefg
607def test_streams_build_logs_as_json_ndjson( 1abcdefg
608 logged_in_cli: None,
609 build_logs_deployment: dict[str, Any],
610 respx_mock: respx.MockRouter,
611) -> None:
612 deployment_id = "00000000-0000-4000-8000-000000000003" 2Zb0b1b2b3b4b5b
613 respx_mock.get(f"/deployments/{deployment_id}/build-logs").mock( 2Zb0b1b2b3b4b5b
614 return_value=Response(
615 200,
616 content=build_logs_response(
617 {
618 "type": "message",
619 "id": "1748682000000-0",
620 "message": "Installing dependencies",
621 },
622 {"type": "complete", "id": "1748682000001-0"},
623 ),
624 )
625 )
627 result = runner.invoke( 2Zb0b1b2b3b4b5b
628 app,
629 ["deployments", "build-logs", deployment_id, "--json"],
630 )
632 assert result.exit_code == 0 2Zb0b1b2b3b4b5b
633 assert [json.loads(line) for line in result.stdout.splitlines()] == [ 2Zb0b1b2b3b4b5b
634 {
635 "type": "log",
636 "deployment_id": deployment_id,
637 "id": "1748682000000-0",
638 "message": "Installing dependencies",
639 },
640 {
641 "type": "complete",
642 "deployment_id": deployment_id,
643 "id": "1748682000001-0",
644 },
645 ]
646 assert result.stderr == "" 2Zb0b1b2b3b4b5b
649def test_build_logs_json_returns_not_logged_in_when_logged_out( 1abcdefg
650 logged_out_cli: None,
651) -> None:
652 result = runner.invoke( 2(c)c*c+c,c-c.c
653 app,
654 [
655 "deployments",
656 "build-logs",
657 "00000000-0000-4000-8000-000000000003",
658 "--json",
659 ],
660 )
662 assert result.exit_code == 1 2(c)c*c+c,c-c.c
663 assert json.loads(result.stdout) == { 2(c)c*c+c,c-c.c
664 "error": {
665 "code": "not_logged_in",
666 "message": "No credentials found.",
667 "hint": "Run `fastapi cloud login`.",
668 }
669 }
670 assert result.stderr == "" 2(c)c*c+c,c-c.c
673def test_streaming_build_logs_handles_keyboard_interrupt( 1abcdefg
674 logged_in_cli: None,
675 build_logs_deployment: dict[str, Any],
676) -> None:
677 deployment_id = "00000000-0000-4000-8000-000000000003" 2/c:c;c=c?c@c[c
679 with patch( 2/c:c;c=c?c@c[c
680 "fastapi_cloud_cli.api.APIClient.stream_build_logs",
681 side_effect=KeyboardInterrupt,
682 ):
683 result = runner.invoke( 2/c:c;c=c?c@c[c
684 app,
685 ["deployments", "build-logs", deployment_id],
686 )
688 assert result.exit_code == 0 2/c:c;c=c?c@c[c
691def test_build_logs_handles_not_found_stream_error( 1abcdefg
692 logged_in_cli: None,
693 build_logs_deployment: dict[str, Any],
694) -> None:
695 deployment_id = "00000000-0000-4000-8000-000000000003" 2lcmcncocpcqcrc
697 with patch( 2lcmcncocpcqcrc
698 "fastapi_cloud_cli.api.APIClient.stream_build_logs",
699 side_effect=StreamLogError("not found", status_code=404),
700 ):
701 result = runner.invoke( 2lcmcncocpcqcrc
702 app,
703 ["deployments", "build-logs", deployment_id],
704 )
706 assert result.exit_code == 1 2lcmcncocpcqcrc
707 assert "Deployment not found." in result.output 2lcmcncocpcqcrc
710def test_build_logs_handles_http_stream_error_with_hint( 1abcdefg
711 logged_in_cli: None,
712 build_logs_deployment: dict[str, Any],
713) -> None:
714 deployment_id = "00000000-0000-4000-8000-000000000003" 1hijklmn
716 def raise_stream_error(*args: object, **kwargs: object) -> None: 1hijklmn
717 request = httpx.Request("GET", "https://api.example.test/build-logs") 1hijklmn
718 response = httpx.Response(401, request=request) 1hijklmn
719 error = httpx.HTTPStatusError( 1hijklmn
720 "Unauthorized", request=request, response=response
721 )
723 try: 1hijklmn
724 raise error 1hijklmn
725 except httpx.HTTPStatusError as exc: 1hijklmn
726 raise StreamLogError("HTTP 401") from exc 1hijklmn
728 with patch( 1hijklmn
729 "fastapi_cloud_cli.api.APIClient.stream_build_logs",
730 side_effect=raise_stream_error,
731 ):
732 result = runner.invoke( 1hijklmn
733 app,
734 ["deployments", "build-logs", deployment_id],
735 )
737 assert result.exit_code == 1 1hijklmn
738 assert "token is not valid" in result.output 1hijklmn
739 assert "fastapi cloud login" in result.output 1hijklmn
742def test_build_logs_handles_generic_stream_error( 1abcdefg
743 logged_in_cli: None,
744 build_logs_deployment: dict[str, Any],
745) -> None:
746 deployment_id = "00000000-0000-4000-8000-000000000003" 2sctcucvcwcxcyc
748 with patch( 2sctcucvcwcxcyc
749 "fastapi_cloud_cli.api.APIClient.stream_build_logs",
750 side_effect=StreamLogError("Log storage unavailable"),
751 ):
752 result = runner.invoke( 2sctcucvcwcxcyc
753 app,
754 ["deployments", "build-logs", deployment_id],
755 )
757 assert result.exit_code == 1 2sctcucvcwcxcyc
758 assert "Error streaming build logs: Log storage unavailable" in result.output 2sctcucvcwcxcyc
761def test_build_logs_handles_connection_loss( 1abcdefg
762 logged_in_cli: None,
763 build_logs_deployment: dict[str, Any],
764) -> None:
765 deployment_id = "00000000-0000-4000-8000-000000000003" 26b7b8b9b!b#b$b
767 with patch( 26b7b8b9b!b#b$b
768 "fastapi_cloud_cli.api.APIClient.stream_build_logs",
769 side_effect=TooManyRetriesError("Connection lost"),
770 ):
771 result = runner.invoke( 26b7b8b9b!b#b$b
772 app,
773 ["deployments", "build-logs", deployment_id],
774 )
776 assert result.exit_code == 1 26b7b8b9b!b#b$b
777 assert "Lost connection to build log stream" in result.output 26b7b8b9b!b#b$b
778 assert "Please try again later." in result.output 26b7b8b9b!b#b$b
781@pytest.mark.respx 1abcdefg
782def test_streams_failed_build_logs_as_json_ndjson_exits_nonzero( 1abcdefg
783 logged_in_cli: None,
784 build_logs_deployment: dict[str, Any],
785 respx_mock: respx.MockRouter,
786) -> None:
787 deployment_id = "00000000-0000-4000-8000-000000000003" 2%b'b(b)b*b+b,b
788 respx_mock.get(f"/deployments/{deployment_id}/build-logs").mock( 2%b'b(b)b*b+b,b
789 return_value=Response(
790 200,
791 content=build_logs_response(
792 {
793 "type": "message",
794 "id": "1748682000000-0",
795 "message": "Installing dependencies",
796 },
797 {"type": "failed", "id": "1748682000001-0"},
798 ),
799 )
800 )
802 result = runner.invoke( 2%b'b(b)b*b+b,b
803 app,
804 ["deployments", "build-logs", deployment_id, "--json"],
805 )
807 assert result.exit_code == 1 2%b'b(b)b*b+b,b
808 assert [json.loads(line) for line in result.stdout.splitlines()] == [ 2%b'b(b)b*b+b,b
809 {
810 "type": "log",
811 "deployment_id": deployment_id,
812 "id": "1748682000000-0",
813 "message": "Installing dependencies",
814 },
815 {
816 "type": "failed",
817 "deployment_id": deployment_id,
818 "id": "1748682000001-0",
819 },
820 ]
821 assert result.stderr == "" 2%b'b(b)b*b+b,b
824@pytest.mark.respx 1abcdefg
825@pytest.mark.parametrize("follow_option", ["--follow", "--no-follow"]) 1abcdefg
826def test_shows_build_failure_diagnostic_in_human_output( 1abcdefg
827 logged_in_cli: None,
828 build_logs_deployment: dict[str, Any],
829 respx_mock: respx.MockRouter,
830 follow_option: str,
831) -> None:
832 deployment_id = "00000000-0000-4000-8000-000000000003" 1opqrstu
833 build_logs_deployment.update( 1opqrstu
834 status="building_image_failed",
835 failure={
836 "error_code": "uv_lockfile_outdated",
837 "error_title": "Your lockfile is out of date",
838 "error_message": "The lockfile does not match your project dependencies.",
839 "error_hint": "Run `uv lock` and commit the updated lockfile.",
840 },
841 )
842 if follow_option == "--follow": 1opqrstu
843 respx_mock.get(f"/deployments/{deployment_id}").mock( 1opqrstu
844 side_effect=[
845 Response(
846 200,
847 json={
848 **build_logs_deployment,
849 "status": "building_image",
850 "failure": None,
851 },
852 ),
853 Response(200, json=build_logs_deployment),
854 ]
855 )
856 respx_mock.get(f"/deployments/{deployment_id}/build-logs").respond( 1opqrstu
857 200,
858 content=build_logs_response(
859 {"type": "message", "id": "1", "message": "Installing dependencies"},
860 {"type": "message", "id": "2", "message": "\n"},
861 {"type": "message", "id": "3", "message": "Cleaning up..."},
862 {"type": "failed", "id": "4"},
863 ),
864 )
866 result = runner.invoke( 1opqrstu
867 app, ["deployments", "build-logs", deployment_id, follow_option]
868 )
870 assert result.exit_code == 1 1opqrstu
871 if follow_option == "--no-follow": 1opqrstu
872 assert result.output == snapshot("""\ 1opqrstu
873📜 Fetching build logs for 00000000-0000-4000-8000-000000000003...
875▕ Installing dependencies
876▕
877▕ Cleaning up...
879🚨 This deployment failed with the following error:
881 Your lockfile is out of date
883 The lockfile does not match your project dependencies.
885 hint: Run `uv lock` and commit the updated lockfile.\
886""")
887 else:
888 assert result.output == snapshot("""\ 1opqrstu
889📡 Streaming build logs for 00000000-0000-4000-8000-000000000003...
891▕ Installing dependencies
892▕
893▕ Cleaning up...
895🚨 This deployment failed with the following error:
897 Your lockfile is out of date
899 The lockfile does not match your project dependencies.
901 hint: Run `uv lock` and commit the updated lockfile.\
902""")
905@pytest.mark.respx 1abcdefg
906def test_gets_build_failure_diagnostic_without_logs_or_hint( 1abcdefg
907 logged_in_cli: None,
908 build_logs_deployment: dict[str, Any],
909 respx_mock: respx.MockRouter,
910) -> None:
911 deployment_id = "00000000-0000-4000-8000-000000000003" 2-b.b/b:b;b=b?b
912 build_logs_deployment.update( 2-b.b/b:b;b=b?b
913 status="building_image_failed",
914 failure={
915 "error_code": "future_build_error",
916 "error_title": "Cannot install project[dev]",
917 "error_message": "The dependency project[dev] could not be installed.",
918 "error_hint": "",
919 },
920 )
921 respx_mock.get(f"/deployments/{deployment_id}/build-logs").respond( 2-b.b/b:b;b=b?b
922 200,
923 content=build_logs_response({"type": "timeout"}),
924 )
926 result = runner.invoke( 2-b.b/b:b;b=b?b
927 app, ["deployments", "build-logs", deployment_id, "--no-follow"]
928 )
930 assert result.exit_code == 1 2-b.b/b:b;b=b?b
931 assert result.output == snapshot("""\ 2-b.b/b:b;b=b?b
932📜 Fetching build logs for 00000000-0000-4000-8000-000000000003...
934🚨 This deployment failed with the following error:
936 Cannot install project[dev]
938 The dependency project[dev] could not be installed.\
939""")
942@pytest.mark.respx 1abcdefg
943@pytest.mark.parametrize("hint", ["Run `uv lock` and commit the updated lockfile.", ""]) 1abcdefg
944def test_streams_build_failure_diagnostic_as_json( 1abcdefg
945 logged_in_cli: None,
946 build_logs_deployment: dict[str, Any],
947 respx_mock: respx.MockRouter,
948 hint: str,
949) -> None:
950 deployment_id = "00000000-0000-4000-8000-000000000003" 1CDEFGHI
951 failure = { 1CDEFGHI
952 "error_code": "uv_lockfile_outdated",
953 "error_title": "Your lockfile is out of date",
954 "error_message": "The lockfile does not match your project dependencies.",
955 "error_hint": hint,
956 }
957 build_logs_deployment.update(status="building_image_failed", failure=failure) 1CDEFGHI
958 respx_mock.get(f"/deployments/{deployment_id}/build-logs").respond( 1CDEFGHI
959 200,
960 content=build_logs_response(
961 {
962 "type": "message",
963 "id": "0",
964 "message": "Error: Building and pushing the app image failed.\n",
965 },
966 {"type": "message", "id": "2", "message": "Cleaning up..."},
967 {"type": "failed", "id": "3"},
968 ),
969 )
971 result = runner.invoke(app, ["deployments", "build-logs", deployment_id, "--json"]) 1CDEFGHI
973 assert result.exit_code == 1 1CDEFGHI
974 assert [json.loads(line) for line in result.stdout.splitlines()] == [ 1CDEFGHI
975 {
976 "type": "log",
977 "deployment_id": deployment_id,
978 "id": "0",
979 "message": "Error: Building and pushing the app image failed.\n",
980 },
981 {
982 "type": "log",
983 "deployment_id": deployment_id,
984 "id": "2",
985 "message": "Cleaning up...",
986 },
987 {
988 "type": "failed",
989 "deployment_id": deployment_id,
990 "id": "3",
991 "failure": failure,
992 },
993 ]
994 assert result.stderr == "" 1CDEFGHI
997@pytest.mark.respx 1abcdefg
998def test_gets_build_failure_diagnostic_as_json( 1abcdefg
999 logged_in_cli: None,
1000 build_logs_deployment: dict[str, Any],
1001 respx_mock: respx.MockRouter,
1002) -> None:
1003 deployment_id = "00000000-0000-4000-8000-000000000003" 1JKLMNOP
1004 failure = { 1JKLMNOP
1005 "error_code": "uv_lockfile_outdated",
1006 "error_title": "Your lockfile is out of date",
1007 "error_message": "The lockfile does not match your project dependencies.",
1008 "error_hint": "Run `uv lock` and commit the updated lockfile.",
1009 }
1010 build_logs_deployment.update(status="building_image_failed", failure=failure) 1JKLMNOP
1011 respx_mock.get(f"/deployments/{deployment_id}/build-logs").respond( 1JKLMNOP
1012 200,
1013 content=build_logs_response(
1014 {
1015 "type": "message",
1016 "id": "0",
1017 "message": "Error: Building and pushing the app image failed.\n",
1018 },
1019 {"type": "failed", "id": "2"},
1020 ),
1021 )
1023 result = runner.invoke( 1JKLMNOP
1024 app, ["deployments", "build-logs", deployment_id, "--no-follow", "--json"]
1025 )
1027 assert result.exit_code == 1 1JKLMNOP
1028 assert json.loads(result.stdout) == { 1JKLMNOP
1029 "data": {
1030 "deployment_id": deployment_id,
1031 "failed": True,
1032 "logs": [
1033 {
1034 "id": "0",
1035 "message": "Error: Building and pushing the app image failed.\n",
1036 },
1037 ],
1038 "failure": failure,
1039 }
1040 }
1041 assert result.stderr == "" 1JKLMNOP
1044@pytest.mark.respx 1abcdefg
1045@pytest.mark.parametrize("follow_option", ["--follow", "--no-follow"]) 1abcdefg
1046def test_generic_build_failure_without_logs( 1abcdefg
1047 logged_in_cli: None,
1048 build_logs_deployment: dict[str, Any],
1049 respx_mock: respx.MockRouter,
1050 follow_option: str,
1051) -> None:
1052 deployment_id = "00000000-0000-4000-8000-000000000003" 1+,-./:;
1053 respx_mock.get(f"/deployments/{deployment_id}/build-logs").respond( 1+,-./:;
1054 200,
1055 content=build_logs_response(
1056 {
1057 "type": "message",
1058 "id": "0",
1059 "message": "Error: Building and pushing the app image failed.\n",
1060 },
1061 {"type": "failed", "id": "1"},
1062 ),
1063 )
1065 result = runner.invoke( 1+,-./:;
1066 app, ["deployments", "build-logs", deployment_id, follow_option]
1067 )
1069 assert result.exit_code == 1 1+,-./:;
1070 assert "Build failed." in result.output 1+,-./:;
1071 assert "Error: Building and pushing the app image failed." in result.output 1+,-./:;
1072 assert "No build logs found." not in result.output 1+,-./:;
1075@pytest.mark.respx 1abcdefg
1076@pytest.mark.parametrize("follow_option", ["--follow", "--no-follow"]) 1abcdefg
1077def test_build_logs_shows_persisted_failure_after_logs_expire( 1abcdefg
1078 logged_in_cli: None,
1079 build_logs_deployment: dict[str, Any],
1080 respx_mock: respx.MockRouter,
1081 follow_option: str,
1082) -> None:
1083 deployment_id = build_logs_deployment["id"] 1vwxyzAB
1084 failure = { 1vwxyzAB
1085 "error_code": "uv_lockfile_outdated",
1086 "error_title": "Your lockfile is out of date",
1087 "error_message": "The lockfile does not match your project dependencies.",
1088 "error_hint": "Run `uv lock` and commit the updated lockfile.",
1089 }
1090 build_logs_deployment.update(status="building_image_failed", failure=failure) 1vwxyzAB
1091 respx_mock.get(f"/deployments/{deployment_id}/build-logs", params__eq={}).respond( 1vwxyzAB
1092 200, content=build_logs_response({"type": "timeout"})
1093 )
1095 result = runner.invoke( 1vwxyzAB
1096 app, ["deployments", "build-logs", deployment_id, follow_option]
1097 )
1099 assert result.exit_code == 1 1vwxyzAB
1100 assert failure["error_title"] in result.output 1vwxyzAB
1101 assert failure["error_message"] in result.output 1vwxyzAB
1102 assert failure["error_hint"] in result.output 1vwxyzAB
1103 assert "No build logs found." not in result.output 1vwxyzAB
1106@pytest.mark.respx 1abcdefg
1107@pytest.mark.parametrize("follow_option", ["--follow", "--no-follow"]) 1abcdefg
1108def test_build_logs_does_not_report_readiness_failure_as_build_failure( 1abcdefg
1109 logged_in_cli: None,
1110 build_logs_deployment: dict[str, Any],
1111 respx_mock: respx.MockRouter,
1112 follow_option: str,
1113) -> None:
1114 deployment_id = build_logs_deployment["id"] 2@b[b]b^b_b`b{b
1115 build_logs_deployment["status"] = "verifying_failed" 2@b[b]b^b_b`b{b
1116 respx_mock.get(f"/deployments/{deployment_id}/build-logs").respond( 2@b[b]b^b_b`b{b
1117 200, content=build_logs_response({"type": "complete", "id": "1"})
1118 )
1120 result = runner.invoke( 2@b[b]b^b_b`b{b
1121 app, ["deployments", "build-logs", deployment_id, follow_option]
1122 )
1124 assert result.exit_code == 0 2@b[b]b^b_b`b{b
1125 assert "Build failed" not in result.output 2@b[b]b^b_b`b{b
1128@pytest.mark.respx 1abcdefg
1129def test_get_deployment_includes_persisted_failure_in_json( 1abcdefg
1130 logged_in_cli: None,
1131 build_logs_deployment: dict[str, Any],
1132) -> None:
1133 failure = { 2zcAcBcCcDcEcFc
1134 "error_code": "uv_lockfile_outdated",
1135 "error_title": "Your lockfile is out of date",
1136 "error_message": "The lockfile does not match your project dependencies.",
1137 "error_hint": "Run `uv lock` and commit the updated lockfile.",
1138 }
1139 build_logs_deployment.update(status="building_image_failed", failure=failure) 2zcAcBcCcDcEcFc
1141 result = runner.invoke( 2zcAcBcCcDcEcFc
1142 app,
1143 [
1144 "deployments",
1145 "get",
1146 build_logs_deployment["id"],
1147 "--app-id",
1148 build_logs_deployment["app_id"],
1149 "--json",
1150 ],
1151 )
1153 assert result.exit_code == 0 2zcAcBcCcDcEcFc
1154 assert json.loads(result.stdout)["data"]["deployment"]["failure"] == failure 2zcAcBcCcDcEcFc
1157@pytest.mark.respx 1abcdefg
1158def test_get_deployment_shows_persisted_failure( 1abcdefg
1159 logged_in_cli: None,
1160 build_logs_deployment: dict[str, Any],
1161) -> None:
1162 failure = { 1=?@[]^_
1163 "error_code": "uv_lockfile_outdated",
1164 "error_title": "Your lockfile is out of date",
1165 "error_message": "The lockfile does not match your project dependencies.",
1166 "error_hint": "Run `uv lock` and commit the updated lockfile.",
1167 }
1168 build_logs_deployment.update(status="building_image_failed", failure=failure) 1=?@[]^_
1170 result = runner.invoke( 1=?@[]^_
1171 app,
1172 [
1173 "deployments",
1174 "get",
1175 build_logs_deployment["id"],
1176 "--app-id",
1177 build_logs_deployment["app_id"],
1178 ],
1179 )
1181 assert result.exit_code == 0 1=?@[]^_
1182 assert failure["error_title"] in result.output 1=?@[]^_
1183 assert failure["error_message"] in result.output 1=?@[]^_
1184 assert failure["error_hint"] in result.output 1=?@[]^_
1187@pytest.mark.respx 1abcdefg
1188def test_build_logs_handles_deployment_lookup_error( 1abcdefg
1189 logged_in_cli: None,
1190 respx_mock: respx.MockRouter,
1191) -> None:
1192 deployment_id = "00000000-0000-4000-8000-000000000003" 2GcHcIcJcKcLcMc
1193 respx_mock.get(f"/deployments/{deployment_id}").respond(404) 2GcHcIcJcKcLcMc
1195 result = runner.invoke(app, ["deployments", "build-logs", deployment_id, "--json"]) 2GcHcIcJcKcLcMc
1197 assert result.exit_code == 1 2GcHcIcJcKcLcMc
1198 assert json.loads(result.stdout)["error"]["message"] == "Deployment not found." 2GcHcIcJcKcLcMc