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

1import json 1abcdefg

2from datetime import datetime, timezone 1abcdefg

3from typing import Any 1abcdefg

4from unittest.mock import patch 1abcdefg

5 

6import httpx 1abcdefg

7import pytest 1abcdefg

8import respx 1abcdefg

9import time_machine 1abcdefg

10from httpx import Response 1abcdefg

11from inline_snapshot import snapshot 1abcdefg

12 

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

17 

18runner = SnapshotCliRunner() 1abcdefg

19 

20 

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

35 

36 

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})) 

57 

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 ) 

72 

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

83 

84 

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})) 

105 

106 with changing_dir(configured_app.path): 1XYZ0123

107 result = runner.invoke(app, ["deployments", "list", "--json"]) 1XYZ0123

108 

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

119 

120 

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})) 

142 

143 result = runner.invoke(app, ["deployments", "list", "--app-id", app_id]) 2` { | } ~ abbb

144 

145 assert result.exit_code == 0 2` { | } ~ abbb

146 assert result.output == snapshot("""\ 2` { | } ~ abbb

147deployments 

148 

149ID Status Created 

150 

15100000000-0000-4000-8000-000000000003 success 2 hours ago\ 

152""") 

153 

154 

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})) 

165 

166 result = runner.invoke(app, ["deployments", "list", "--app-id", app_id]) 2|b}b~bacbcccdc

167 

168 assert result.exit_code == 0 2|b}b~bacbcccdc

169 assert result.output == snapshot("""\ 2|b}b~bacbcccdc

170deployments 

171 

172No deployments found.\ 

173""") 

174 

175 

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 ) 

189 

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

199 

200 

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

205 

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

215 

216 

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 ) 

231 

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

241 

242 

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 ) 

262 

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 ) 

274 

275 assert result.exit_code == 0 1456789!

276 assert json.loads(result.stdout) == {"data": {"deployment": deployment}} 1456789!

277 assert result.stderr == "" 1456789!

278 

279 

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 ) 

299 

300 with changing_dir(configured_app.path): 1#$%'()*

301 result = runner.invoke(app, ["deployments", "get", deployment["id"], "--json"]) 1#$%'()*

302 

303 assert result.exit_code == 0 1#$%'()*

304 assert json.loads(result.stdout) == {"data": {"deployment": deployment}} 1#$%'()*

305 assert result.stderr == "" 1#$%'()*

306 

307 

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 ) 

315 

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

325 

326 

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 ) 

347 

348 result = runner.invoke( 2cbdbebfbgbhbib

349 app, 

350 ["deployments", "get", deployment["id"], "--app-id", app_id], 

351 ) 

352 

353 assert result.exit_code == 0 2cbdbebfbgbhbib

354 assert result.output == snapshot("""\ 2cbdbebfbgbhbib

355deployment 

356 

357🚀 00000000-0000-4000-8000-000000000003 

358 

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""") 

366 

367 

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 ) 

392 

393 result = runner.invoke( 2jbkblbmbnbobpb

394 app, 

395 ["deployments", "build-logs", deployment_id, "--no-follow", "--json"], 

396 ) 

397 

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

417 

418 

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 ) 

439 

440 result = runner.invoke( 2qbrbsbtbubvbwb

441 app, 

442 ["deployments", "build-logs", deployment_id, "--no-follow", "--json"], 

443 ) 

444 

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

460 

461 

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 ) 

482 

483 result = runner.invoke( 2xbybzbAbBbCbDb

484 app, 

485 ["deployments", "build-logs", deployment_id, "--no-follow"], 

486 ) 

487 

488 assert result.exit_code == 0 2xbybzbAbBbCbDb

489 assert "Fetching build logs" in result.output 2xbybzbAbBbCbDb

490 assert "Installing dependencies" in result.output 2xbybzbAbBbCbDb

491 

492 

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 ) 

503 

504 result = runner.invoke( 2ecfcgchcicjckc

505 app, 

506 ["deployments", "build-logs", deployment_id, "--no-follow"], 

507 ) 

508 

509 assert result.exit_code == 0 2ecfcgchcicjckc

510 assert "No build logs found." in result.output 2ecfcgchcicjckc

511 

512 

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 ) 

533 

534 result = runner.invoke( 2EbFbGbHbIbJbKb

535 app, 

536 ["deployments", "build-logs", deployment_id, "--no-follow"], 

537 ) 

538 

539 assert result.exit_code == 1 2EbFbGbHbIbJbKb

540 assert "Installing dependencies" in result.output 2EbFbGbHbIbJbKb

541 assert "Build failed." in result.output 2EbFbGbHbIbJbKb

542 

543 

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 ) 

564 

565 result = runner.invoke( 2LbMbNbObPbQbRb

566 app, 

567 ["deployments", "build-logs", deployment_id], 

568 ) 

569 

570 assert result.exit_code == 0 2LbMbNbObPbQbRb

571 assert "Streaming build logs" in result.output 2LbMbNbObPbQbRb

572 assert "Installing dependencies" in result.output 2LbMbNbObPbQbRb

573 

574 

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 ) 

595 

596 result = runner.invoke( 2SbTbUbVbWbXbYb

597 app, 

598 ["deployments", "build-logs", deployment_id], 

599 ) 

600 

601 assert result.exit_code == 1 2SbTbUbVbWbXbYb

602 assert "Installing dependencies" in result.output 2SbTbUbVbWbXbYb

603 assert "Build failed." in result.output 2SbTbUbVbWbXbYb

604 

605 

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 ) 

626 

627 result = runner.invoke( 2Zb0b1b2b3b4b5b

628 app, 

629 ["deployments", "build-logs", deployment_id, "--json"], 

630 ) 

631 

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

647 

648 

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 ) 

661 

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

671 

672 

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

678 

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 ) 

687 

688 assert result.exit_code == 0 2/c:c;c=c?c@c[c

689 

690 

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

696 

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 ) 

705 

706 assert result.exit_code == 1 2lcmcncocpcqcrc

707 assert "Deployment not found." in result.output 2lcmcncocpcqcrc

708 

709 

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

715 

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 ) 

722 

723 try: 1hijklmn

724 raise error 1hijklmn

725 except httpx.HTTPStatusError as exc: 1hijklmn

726 raise StreamLogError("HTTP 401") from exc 1hijklmn

727 

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 ) 

736 

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

740 

741 

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

747 

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 ) 

756 

757 assert result.exit_code == 1 2sctcucvcwcxcyc

758 assert "Error streaming build logs: Log storage unavailable" in result.output 2sctcucvcwcxcyc

759 

760 

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

766 

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 ) 

775 

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

779 

780 

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 ) 

801 

802 result = runner.invoke( 2%b'b(b)b*b+b,b

803 app, 

804 ["deployments", "build-logs", deployment_id, "--json"], 

805 ) 

806 

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

822 

823 

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 ) 

865 

866 result = runner.invoke( 1opqrstu

867 app, ["deployments", "build-logs", deployment_id, follow_option] 

868 ) 

869 

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... 

874 

875▕ Installing dependencies 

876▕ 

877▕ Cleaning up... 

878 

879🚨 This deployment failed with the following error: 

880 

881 Your lockfile is out of date 

882 

883 The lockfile does not match your project dependencies. 

884 

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... 

890 

891▕ Installing dependencies 

892▕ 

893▕ Cleaning up... 

894 

895🚨 This deployment failed with the following error: 

896 

897 Your lockfile is out of date 

898 

899 The lockfile does not match your project dependencies. 

900 

901 hint: Run `uv lock` and commit the updated lockfile.\ 

902""") 

903 

904 

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 ) 

925 

926 result = runner.invoke( 2-b.b/b:b;b=b?b

927 app, ["deployments", "build-logs", deployment_id, "--no-follow"] 

928 ) 

929 

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... 

933 

934🚨 This deployment failed with the following error: 

935 

936 Cannot install project[dev] 

937 

938 The dependency project[dev] could not be installed.\ 

939""") 

940 

941 

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 ) 

970 

971 result = runner.invoke(app, ["deployments", "build-logs", deployment_id, "--json"]) 1CDEFGHI

972 

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

995 

996 

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 ) 

1022 

1023 result = runner.invoke( 1JKLMNOP

1024 app, ["deployments", "build-logs", deployment_id, "--no-follow", "--json"] 

1025 ) 

1026 

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

1042 

1043 

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 ) 

1064 

1065 result = runner.invoke( 1+,-./:;

1066 app, ["deployments", "build-logs", deployment_id, follow_option] 

1067 ) 

1068 

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+,-./:;

1073 

1074 

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 ) 

1094 

1095 result = runner.invoke( 1vwxyzAB

1096 app, ["deployments", "build-logs", deployment_id, follow_option] 

1097 ) 

1098 

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

1104 

1105 

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 ) 

1119 

1120 result = runner.invoke( 2@b[b]b^b_b`b{b

1121 app, ["deployments", "build-logs", deployment_id, follow_option] 

1122 ) 

1123 

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

1126 

1127 

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

1140 

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 ) 

1152 

1153 assert result.exit_code == 0 2zcAcBcCcDcEcFc

1154 assert json.loads(result.stdout)["data"]["deployment"]["failure"] == failure 2zcAcBcCcDcEcFc

1155 

1156 

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=?@[]^_

1169 

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 ) 

1180 

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=?@[]^_

1185 

1186 

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

1194 

1195 result = runner.invoke(app, ["deployments", "build-logs", deployment_id, "--json"]) 2GcHcIcJcKcLcMc

1196 

1197 assert result.exit_code == 1 2GcHcIcJcKcLcMc

1198 assert json.loads(result.stdout)["error"]["message"] == "Deployment not found." 2GcHcIcJcKcLcMc