Coverage for tests/test_cli_apps.py: 100%

278 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 pathlib import Path 1abcdefg

4from unittest.mock import patch 1abcdefg

5 

6import pytest 1abcdefg

7import respx 1abcdefg

8import time_machine 1abcdefg

9from httpx import Response 1abcdefg

10from inline_snapshot import snapshot 1abcdefg

11 

12from fastapi_cloud_cli.cli import cloud_app as app 1abcdefg

13from fastapi_cloud_cli.commands.apps.list import ( 1abcdefg

14 App, 

15 _get_app_dashboard_url, 

16) 

17from fastapi_cloud_cli.config import Settings 1abcdefg

18from fastapi_cloud_cli.utils.apps import AppConfig 1abcdefg

19from tests.conftest import ConfiguredApp 1abcdefg

20from tests.utils import Keys, SnapshotCliRunner, changing_dir 1abcdefg

21 

22runner = SnapshotCliRunner() 1abcdefg

23 

24 

25def test_creates_app_json_returns_not_logged_in_when_logged_out( 1abcdefg

26 logged_out_cli: None, 

27) -> None: 

28 result = runner.invoke( 2Zb0b1b2b3b4b5b

29 app, 

30 [ 

31 "apps", 

32 "create", 

33 "--team-id", 

34 "00000000-0000-4000-8000-000000000001", 

35 "--name", 

36 "API", 

37 "--json", 

38 ], 

39 ) 

40 

41 assert result.exit_code == 1 2Zb0b1b2b3b4b5b

42 assert json.loads(result.stdout) == { 2Zb0b1b2b3b4b5b

43 "error": { 

44 "code": "not_logged_in", 

45 "message": "No credentials found.", 

46 "hint": "Run `fastapi cloud login`.", 

47 } 

48 } 

49 assert result.stderr == "" 2Zb0b1b2b3b4b5b

50 

51 

52@pytest.mark.respx 1abcdefg

53def test_creates_app_as_json_without_link_by_default( 1abcdefg

54 logged_in_cli: None, 

55 respx_mock: respx.MockRouter, 

56 tmp_path: Path, 

57) -> None: 

58 team_id = "00000000-0000-4000-8000-000000000001" 1STUVWXY

59 app_id = "00000000-0000-4000-8000-000000000002" 1STUVWXY

60 app_data = { 1STUVWXY

61 "id": app_id, 

62 "team_id": team_id, 

63 "slug": "api", 

64 "name": "API", 

65 "directory": "backend", 

66 } 

67 respx_mock.post( 1STUVWXY

68 "/apps/", 

69 json={"team_id": team_id, "name": "API", "directory": "backend"}, 

70 ).mock(return_value=Response(201, json=app_data)) 

71 

72 with changing_dir(tmp_path): 1STUVWXY

73 result = runner.invoke( 1STUVWXY

74 app, 

75 [ 

76 "apps", 

77 "create", 

78 "--team-id", 

79 team_id, 

80 "--name", 

81 "API", 

82 "--directory", 

83 "backend", 

84 "--json", 

85 ], 

86 ) 

87 

88 assert result.exit_code == 0 1STUVWXY

89 assert json.loads(result.stdout) == { 1STUVWXY

90 "data": { 

91 "app": app_data, 

92 "linked": False, 

93 } 

94 } 

95 assert not (tmp_path / ".fastapicloud" / "cloud.json").exists() 1STUVWXY

96 assert result.stderr == "" 1STUVWXY

97 

98 

99@pytest.mark.respx 1abcdefg

100def test_creates_app_and_links_to_path_when_link_is_explicit( 1abcdefg

101 logged_in_cli: None, 

102 respx_mock: respx.MockRouter, 

103 tmp_path: Path, 

104) -> None: 

105 team_id = "00000000-0000-4000-8000-000000000001" 1vwxyzAB

106 app_id = "00000000-0000-4000-8000-000000000002" 1vwxyzAB

107 app_data = { 1vwxyzAB

108 "id": app_id, 

109 "team_id": team_id, 

110 "slug": "api", 

111 "name": "API", 

112 "directory": "backend", 

113 } 

114 path_to_link = tmp_path / "repo" 1vwxyzAB

115 path_to_link.mkdir() 1vwxyzAB

116 config_path = path_to_link / ".fastapicloud" / "cloud.json" 1vwxyzAB

117 respx_mock.post( 1vwxyzAB

118 "/apps/", 

119 json={"team_id": team_id, "name": "API", "directory": "backend"}, 

120 ).mock(return_value=Response(201, json=app_data)) 

121 

122 result = runner.invoke( 1vwxyzAB

123 app, 

124 [ 

125 "apps", 

126 "create", 

127 "--team-id", 

128 team_id, 

129 "--name", 

130 "API", 

131 "--directory", 

132 "backend", 

133 "--link", 

134 "--path", 

135 str(path_to_link), 

136 "--json", 

137 ], 

138 ) 

139 

140 assert result.exit_code == 0 1vwxyzAB

141 assert json.loads(result.stdout) == { 1vwxyzAB

142 "data": { 

143 "app": app_data, 

144 "linked": True, 

145 } 

146 } 

147 assert AppConfig.model_validate_json(config_path.read_text(encoding="utf-8")) == ( 1vwxyzAB

148 AppConfig(app_id=app_id, team_id=team_id) 

149 ) 

150 assert result.stderr == "" 1vwxyzAB

151 

152 

153@pytest.mark.respx 1abcdefg

154def test_creates_app_prompts_for_team_when_team_id_is_missing( 1abcdefg

155 logged_in_cli: None, 

156 respx_mock: respx.MockRouter, 

157 tmp_path: Path, 

158) -> None: 

159 team_id = "00000000-0000-4000-8000-000000000001" 1CZ01D23

160 team_data = { 1CZ01D23

161 "id": team_id, 

162 "slug": "acme", 

163 "name": "Acme", 

164 } 

165 app_data = { 1CZ01D23

166 "id": "00000000-0000-4000-8000-000000000002", 

167 "team_id": team_id, 

168 "slug": "api", 

169 "name": "API", 

170 "directory": None, 

171 } 

172 respx_mock.get("/teams/").mock( 1CZ01D23

173 return_value=Response(200, json={"data": [team_data], "count": 1}) 

174 ) 

175 respx_mock.post( 1CZ01D23

176 "/apps/", 

177 json={"team_id": team_id, "name": "API", "directory": None}, 

178 ).mock(return_value=Response(201, json=app_data)) 

179 

180 with ( 1CD

181 changing_dir(tmp_path), 

182 patch("rich_toolkit.container.getchar") as mock_getchar, 

183 ): 

184 mock_getchar.side_effect = [Keys.ENTER] 1CZ01D23

185 result = runner.invoke(app, ["apps", "create", "--name", "API", "--no-link"]) 1CZ01D23

186 

187 assert result.exit_code == 0 1CZ01D23

188 assert result.output == snapshot("""\ 1CZ01D23

189Select the team: 

190Filter: 

191 

192● Acme 

193 

194Select the team: Acme 

195 

196Created app API\ 

197""") 

198 assert not (tmp_path / ".fastapicloud" / "cloud.json").exists() 1CZ01D23

199 

200 

201@pytest.mark.respx 1abcdefg

202def test_creates_app_prompts_for_name_and_links_by_default( 1abcdefg

203 logged_in_cli: None, 

204 respx_mock: respx.MockRouter, 

205 tmp_path: Path, 

206) -> None: 

207 team_id = "00000000-0000-4000-8000-000000000001" 1hijklmn

208 app_id = "00000000-0000-4000-8000-000000000002" 1hijklmn

209 app_data = { 1hijklmn

210 "id": app_id, 

211 "team_id": team_id, 

212 "slug": "api", 

213 "name": "API", 

214 "directory": None, 

215 } 

216 path_to_link = tmp_path / "repo" 1hijklmn

217 path_to_link.mkdir() 1hijklmn

218 config_path = path_to_link / ".fastapicloud" / "cloud.json" 1hijklmn

219 respx_mock.post( 1hijklmn

220 "/apps/", 

221 json={"team_id": team_id, "name": "API", "directory": None}, 

222 ).mock(return_value=Response(201, json=app_data)) 

223 

224 with patch("rich_toolkit.container.getchar") as mock_getchar: 1hijklmn

225 mock_getchar.side_effect = [*"API", Keys.ENTER] 1hijklmn

226 result = runner.invoke( 1hijklmn

227 app, 

228 [ 

229 "apps", 

230 "create", 

231 "--team-id", 

232 team_id, 

233 "--path", 

234 str(path_to_link), 

235 ], 

236 ) 

237 

238 assert result.exit_code == 0 1hijklmn

239 assert "What's your app name?" in result.output 1hijklmn

240 assert "Created app API" in result.output 1hijklmn

241 assert "Linked" in result.output 1hijklmn

242 # join wrapped lines: where the message wraps depends on the tmp path length 

243 assert "to API" in " ".join(result.output.split()) 1hijklmn

244 assert AppConfig.model_validate_json(config_path.read_text(encoding="utf-8")) == ( 1hijklmn

245 AppConfig(app_id=app_id, team_id=team_id) 

246 ) 

247 

248 

249@pytest.mark.parametrize( 1abcdefg

250 ("args", "message", "hint"), 

251 [ 

252 ( 

253 ["--name", "API"], 

254 "Team ID is required.", 

255 "Pass --team-id to choose a team.", 

256 ), 

257 ( 

258 ["--team-id", "00000000-0000-4000-8000-000000000001"], 

259 "App name is required.", 

260 "Pass --name to choose an app name.", 

261 ), 

262 ], 

263) 

264def test_creates_app_json_requires_team_id_and_name( 1abcdefg

265 logged_in_cli: None, 

266 args: list[str], 

267 message: str, 

268 hint: str, 

269) -> None: 

270 result = runner.invoke(app, ["apps", "create", *args, "--json"]) 26b7b8b9b!b#b$b

271 

272 assert result.exit_code == 1 26b7b8b9b!b#b$b

273 assert json.loads(result.stdout) == { 26b7b8b9b!b#b$b

274 "error": { 

275 "code": "missing_required_input", 

276 "message": message, 

277 "hint": hint, 

278 } 

279 } 

280 assert result.stderr == "" 26b7b8b9b!b#b$b

281 

282 

283def test_creates_app_json_rejects_path_without_link( 1abcdefg

284 logged_in_cli: None, 

285 settings: Settings, 

286 tmp_path: Path, 

287) -> None: 

288 team_id = "00000000-0000-4000-8000-000000000001" 1EFGHIJK

289 app_data = { 1EFGHIJK

290 "id": "00000000-0000-4000-8000-000000000002", 

291 "team_id": team_id, 

292 "slug": "api", 

293 "name": "API", 

294 "directory": None, 

295 } 

296 path_to_link = tmp_path / "repo" 1EFGHIJK

297 path_to_link.mkdir() 1EFGHIJK

298 

299 with respx.mock(base_url=settings.base_api_url, assert_all_called=False) as router: 1EFGHIJK

300 router.post( 1EFGHIJK

301 "/apps/", 

302 json={"team_id": team_id, "name": "API", "directory": None}, 

303 ).mock(return_value=Response(201, json=app_data)) 

304 

305 result = runner.invoke( 1EFGHIJK

306 app, 

307 [ 

308 "apps", 

309 "create", 

310 "--team-id", 

311 team_id, 

312 "--name", 

313 "API", 

314 "--path", 

315 str(path_to_link), 

316 "--json", 

317 ], 

318 ) 

319 

320 assert result.exit_code == 1 1EFGHIJK

321 assert json.loads(result.stdout) == { 1EFGHIJK

322 "error": { 

323 "code": "invalid_input", 

324 "message": "Path can only be used when linking.", 

325 "hint": "Pass --link or omit --path.", 

326 } 

327 } 

328 assert not (path_to_link / ".fastapicloud" / "cloud.json").exists() 1EFGHIJK

329 assert result.stderr == "" 1EFGHIJK

330 

331 

332def test_creates_app_json_rejects_invalid_directory(logged_in_cli: None) -> None: 1abcdefg

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

334 app, 

335 [ 

336 "apps", 

337 "create", 

338 "--team-id", 

339 "00000000-0000-4000-8000-000000000001", 

340 "--name", 

341 "API", 

342 "--directory", 

343 "/tmp/api", 

344 "--json", 

345 ], 

346 ) 

347 

348 assert result.exit_code == 1 2%b'b(b)b*b+b,b

349 assert json.loads(result.stdout) == { 2%b'b(b)b*b+b,b

350 "error": { 

351 "code": "invalid_input", 

352 "message": ("Invalid app directory: must be a relative path, not absolute"), 

353 "hint": ( 

354 "Pass a relative app directory such as `backend` or `webserver`; " 

355 "use --path with --link to choose a local filesystem path." 

356 ), 

357 } 

358 } 

359 assert result.stderr == "" 2%b'b(b)b*b+b,b

360 

361 

362def test_updates_app_json_returns_not_logged_in_when_logged_out( 1abcdefg

363 logged_out_cli: None, 

364) -> None: 

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

366 app, 

367 [ 

368 "apps", 

369 "update", 

370 "00000000-0000-4000-8000-000000000002", 

371 "--directory", 

372 "backend", 

373 "--json", 

374 ], 

375 ) 

376 

377 assert result.exit_code == 1 2-b.b/b:b;b=b?b

378 assert json.loads(result.stdout) == { 2-b.b/b:b;b=b?b

379 "error": { 

380 "code": "not_logged_in", 

381 "message": "No credentials found.", 

382 "hint": "Run `fastapi cloud login`.", 

383 } 

384 } 

385 assert result.stderr == "" 2-b.b/b:b;b=b?b

386 

387 

388@pytest.mark.respx 1abcdefg

389def test_updates_app_directory_as_json( 1abcdefg

390 logged_in_cli: None, 

391 respx_mock: respx.MockRouter, 

392) -> None: 

393 app_id = "00000000-0000-4000-8000-000000000002" 2` { | } ~ abbb

394 app_data = { 2` { | } ~ abbb

395 "id": app_id, 

396 "team_id": "00000000-0000-4000-8000-000000000001", 

397 "slug": "api", 

398 "name": "API", 

399 "directory": "backend", 

400 } 

401 respx_mock.patch( 2` { | } ~ abbb

402 f"/apps/{app_id}", 

403 json={"directory": "backend"}, 

404 ).mock(return_value=Response(200, json=app_data)) 

405 

406 result = runner.invoke( 2` { | } ~ abbb

407 app, 

408 [ 

409 "apps", 

410 "update", 

411 app_id, 

412 "--directory", 

413 "backend", 

414 "--json", 

415 ], 

416 ) 

417 

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

419 assert json.loads(result.stdout) == {"data": {"app": app_data}} 2` { | } ~ abbb

420 assert result.stderr == "" 2` { | } ~ abbb

421 

422 

423@pytest.mark.respx 1abcdefg

424def test_updates_linked_app_directory_in_human_output( 1abcdefg

425 logged_in_cli: None, 

426 respx_mock: respx.MockRouter, 

427 configured_app: ConfiguredApp, 

428) -> None: 

429 app_data = { 2xbybzbAbBbCbDb

430 "id": configured_app.app_id, 

431 "team_id": configured_app.team_id, 

432 "slug": "api", 

433 "name": "API", 

434 "directory": "src", 

435 } 

436 respx_mock.patch( 2xbybzbAbBbCbDb

437 f"/apps/{configured_app.app_id}", 

438 json={"directory": "src"}, 

439 ).mock(return_value=Response(200, json=app_data)) 

440 

441 with changing_dir(configured_app.path): 2xbybzbAbBbCbDb

442 result = runner.invoke(app, ["apps", "update", "--directory", "src"]) 2xbybzbAbBbCbDb

443 

444 assert result.exit_code == 0 2xbybzbAbBbCbDb

445 assert result.output == snapshot("""\ 2xbybzbAbBbCbDb

446Updated app API 

447Directory: src\ 

448""") 

449 

450 

451def test_updates_app_json_returns_missing_required_input_without_update_flags( 1abcdefg

452 logged_in_cli: None, 

453) -> None: 

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

455 app, 

456 [ 

457 "apps", 

458 "update", 

459 "00000000-0000-4000-8000-000000000002", 

460 "--json", 

461 ], 

462 ) 

463 

464 assert result.exit_code == 1 2@b[b]b^b_b`b{b

465 assert json.loads(result.stdout) == { 2@b[b]b^b_b`b{b

466 "error": { 

467 "code": "missing_required_input", 

468 "message": "No updates provided.", 

469 "hint": "Pass --directory to update the app directory.", 

470 } 

471 } 

472 assert result.stderr == "" 2@b[b]b^b_b`b{b

473 

474 

475def test_updates_app_json_rejects_invalid_directory(logged_in_cli: None) -> None: 1abcdefg

476 result = runner.invoke( 2|b}b~bacbcccdc

477 app, 

478 [ 

479 "apps", 

480 "update", 

481 "00000000-0000-4000-8000-000000000002", 

482 "--directory", 

483 "/tmp/api", 

484 "--json", 

485 ], 

486 ) 

487 

488 assert result.exit_code == 1 2|b}b~bacbcccdc

489 assert json.loads(result.stdout) == { 2|b}b~bacbcccdc

490 "error": { 

491 "code": "invalid_input", 

492 "message": ("Invalid app directory: must be a relative path, not absolute"), 

493 "hint": "Pass a relative app directory such as `src` or `backend`.", 

494 } 

495 } 

496 assert result.stderr == "" 2|b}b~bacbcccdc

497 

498 

499@pytest.mark.respx 1abcdefg

500def test_links_existing_app_to_path_as_json( 1abcdefg

501 logged_in_cli: None, 

502 respx_mock: respx.MockRouter, 

503 tmp_path: Path, 

504) -> None: 

505 team_id = "00000000-0000-4000-8000-000000000001" 1LMNOPQR

506 app_id = "00000000-0000-4000-8000-000000000002" 1LMNOPQR

507 path_to_link = tmp_path / "repo" 1LMNOPQR

508 path_to_link.mkdir() 1LMNOPQR

509 app_data = { 1LMNOPQR

510 "id": app_id, 

511 "team_id": team_id, 

512 "slug": "api", 

513 "name": "API", 

514 "directory": None, 

515 } 

516 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 1LMNOPQR

517 

518 result = runner.invoke( 1LMNOPQR

519 app, 

520 [ 

521 "apps", 

522 "link", 

523 app_id, 

524 "--path", 

525 str(path_to_link), 

526 "--json", 

527 ], 

528 ) 

529 

530 assert result.exit_code == 0 1LMNOPQR

531 assert json.loads(result.stdout) == { 1LMNOPQR

532 "data": { 

533 "app_id": app_id, 

534 "team_id": team_id, 

535 "path": str(path_to_link), 

536 } 

537 } 

538 assert AppConfig.model_validate_json( 1LMNOPQR

539 (path_to_link / ".fastapicloud" / "cloud.json").read_text(encoding="utf-8") 

540 ) == AppConfig(app_id=app_id, team_id=team_id) 

541 assert result.stderr == "" 1LMNOPQR

542 

543 

544def test_links_existing_app_returns_already_linked_without_force( 1abcdefg

545 logged_in_cli: None, 

546 tmp_path: Path, 

547) -> None: 

548 app_id = "00000000-0000-4000-8000-000000000002" 1456789!

549 path_to_link = tmp_path / "repo" 1456789!

550 config_path = path_to_link / ".fastapicloud" / "cloud.json" 1456789!

551 config_path.parent.mkdir(parents=True) 1456789!

552 config_path.write_text( 1456789!

553 AppConfig(app_id="existing-app", team_id="existing-team").model_dump_json(), 

554 encoding="utf-8", 

555 ) 

556 

557 result = runner.invoke( 1456789!

558 app, 

559 [ 

560 "apps", 

561 "link", 

562 app_id, 

563 "--path", 

564 str(path_to_link), 

565 "--json", 

566 ], 

567 ) 

568 

569 assert result.exit_code == 1 1456789!

570 assert json.loads(result.stdout) == { 1456789!

571 "error": { 

572 "code": "already_linked", 

573 "message": "This directory is already linked to an app.", 

574 "hint": "Pass --force to replace the existing configuration.", 

575 } 

576 } 

577 assert AppConfig.model_validate_json(config_path.read_text(encoding="utf-8")) == ( 1456789!

578 AppConfig(app_id="existing-app", team_id="existing-team") 

579 ) 

580 assert result.stderr == "" 1456789!

581 

582 

583@pytest.mark.respx 1abcdefg

584def test_links_existing_app_replaces_config_with_force( 1abcdefg

585 logged_in_cli: None, 

586 respx_mock: respx.MockRouter, 

587 tmp_path: Path, 

588) -> None: 

589 team_id = "00000000-0000-4000-8000-000000000001" 1opqrstu

590 app_id = "00000000-0000-4000-8000-000000000002" 1opqrstu

591 path_to_link = tmp_path / "repo" 1opqrstu

592 config_path = path_to_link / ".fastapicloud" / "cloud.json" 1opqrstu

593 config_path.parent.mkdir(parents=True) 1opqrstu

594 config_path.write_text( 1opqrstu

595 AppConfig(app_id="existing-app", team_id="existing-team").model_dump_json(), 

596 encoding="utf-8", 

597 ) 

598 app_data = { 1opqrstu

599 "id": app_id, 

600 "team_id": team_id, 

601 "slug": "api", 

602 "name": "API", 

603 "directory": None, 

604 } 

605 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 1opqrstu

606 

607 result = runner.invoke( 1opqrstu

608 app, 

609 [ 

610 "apps", 

611 "link", 

612 app_id, 

613 "--path", 

614 str(path_to_link), 

615 "--force", 

616 "--json", 

617 ], 

618 ) 

619 

620 assert result.exit_code == 0 1opqrstu

621 assert json.loads(result.stdout) == { 1opqrstu

622 "data": { 

623 "app_id": app_id, 

624 "team_id": team_id, 

625 "path": str(path_to_link), 

626 } 

627 } 

628 assert AppConfig.model_validate_json(config_path.read_text(encoding="utf-8")) == ( 1opqrstu

629 AppConfig(app_id=app_id, team_id=team_id) 

630 ) 

631 assert result.stderr == "" 1opqrstu

632 

633 

634@pytest.mark.respx 1abcdefg

635def test_lists_apps_as_json_with_team_and_pagination_params( 1abcdefg

636 logged_in_cli: None, 

637 respx_mock: respx.MockRouter, 

638) -> None: 

639 team_id = "00000000-0000-4000-8000-000000000001" 1#$%'()*

640 team_data = { 1#$%'()*

641 "id": team_id, 

642 "slug": "strawberry", 

643 "name": "Strawberry", 

644 } 

645 app_data = { 1#$%'()*

646 "id": "00000000-0000-4000-8000-000000000002", 

647 "team_id": team_id, 

648 "slug": "api", 

649 "name": "API", 

650 "directory": "backend", 

651 "url": "https://strawberryrocks.fastapicloud.dev", 

652 "region": "us-east-1", 

653 "updated_at": "2026-05-15T12:00:00Z", 

654 } 

655 team_route = respx_mock.get(f"/teams/{team_id}").mock( 1#$%'()*

656 return_value=Response(200, json=team_data) 

657 ) 

658 respx_mock.get( 1#$%'()*

659 "/apps/", 

660 params={"team_id": team_id, "limit": "100", "skip": "20"}, 

661 ).mock(return_value=Response(200, json={"data": [app_data], "count": 1})) 

662 

663 result = runner.invoke( 1#$%'()*

664 app, 

665 [ 

666 "apps", 

667 "list", 

668 "--team-id", 

669 team_id, 

670 "--limit", 

671 "100", 

672 "--offset", 

673 "20", 

674 "--json", 

675 ], 

676 ) 

677 

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

679 assert json.loads(result.stdout) == { 1#$%'()*

680 "data": { 

681 "apps": [app_data], 

682 "total_count": 1, 

683 "limit": 100, 

684 "offset": 20, 

685 } 

686 } 

687 assert team_route.called 1#$%'()*

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

689 

690 

691def test_lists_apps_json_returns_missing_required_input_without_team_id( 1abcdefg

692 logged_in_cli: None, 

693) -> None: 

694 result = runner.invoke(app, ["apps", "list", "--json"]) 2ecfcgchcicjckc

695 

696 assert result.exit_code == 1 2ecfcgchcicjckc

697 assert json.loads(result.stdout) == { 2ecfcgchcicjckc

698 "error": { 

699 "code": "missing_required_input", 

700 "message": "Team ID is required.", 

701 "hint": "Pass --team-id to choose a team.", 

702 } 

703 } 

704 assert result.stderr == "" 2ecfcgchcicjckc

705 

706 

707def test_gets_app_human_returns_not_logged_in_when_logged_out( 1abcdefg

708 logged_out_cli: None, 

709) -> None: 

710 result = runner.invoke( 2sctcucvcwcxcyc

711 app, 

712 ["apps", "get", "00000000-0000-4000-8000-000000000002"], 

713 ) 

714 

715 assert result.exit_code == 1 2sctcucvcwcxcyc

716 assert result.output == snapshot("""\ 2sctcucvcwcxcyc

717✗ error: No credentials found. 

718 

719 hint: Run `fastapi cloud login`.\ 

720""") 

721 

722 

723@pytest.mark.respx 1abcdefg

724def test_lists_apps_prompts_for_team_when_team_id_is_missing( 1abcdefg

725 logged_in_cli: None, 

726 respx_mock: respx.MockRouter, 

727) -> None: 

728 team = { 1+,-./:;

729 "id": "00000000-0000-4000-8000-000000000001", 

730 "slug": "acme", 

731 "name": "Acme", 

732 } 

733 app_data = { 1+,-./:;

734 "id": "00000000-0000-4000-8000-000000000002", 

735 "team_id": team["id"], 

736 "slug": "api", 

737 "name": "API", 

738 "directory": "backend", 

739 } 

740 respx_mock.get("/teams/").mock( 1+,-./:;

741 return_value=Response(200, json={"data": [team], "count": 1}) 

742 ) 

743 apps_route = respx_mock.get("/apps/").mock( 1+,-./:;

744 return_value=Response(200, json={"data": [app_data], "count": 1}) 

745 ) 

746 

747 with patch("rich_toolkit.container.getchar") as mock_getchar: 1+,-./:;

748 mock_getchar.side_effect = [Keys.ENTER] 1+,-./:;

749 result = runner.invoke(app, ["apps", "list"]) 1+,-./:;

750 

751 assert result.exit_code == 0 1+,-./:;

752 assert result.output == snapshot("""\ 1+,-./:;

753Select the team: 

754Filter: 

755 

756● Acme 

757 

758Select the team: Acme 

759 

760apps 

761 

762Name ID 

763 

764API 00000000-0000-4000-8000-000000000002\ 

765""") 

766 assert apps_route.calls.last.request.url.params["team_id"] == team["id"] 1+,-./:;

767 

768 

769@pytest.mark.respx 1abcdefg

770def test_lists_apps_returns_missing_required_input_when_no_teams( 1abcdefg

771 logged_in_cli: None, 

772 respx_mock: respx.MockRouter, 

773) -> None: 

774 respx_mock.get("/teams/").mock( 2lcmcncocpcqcrc

775 return_value=Response(200, json={"data": [], "count": 0}) 

776 ) 

777 

778 result = runner.invoke(app, ["apps", "list"]) 2lcmcncocpcqcrc

779 

780 assert result.exit_code == 1 2lcmcncocpcqcrc

781 assert result.output == snapshot("""\ 2lcmcncocpcqcrc

782✗ error: No teams found. 

783 

784 hint: Create a team before listing apps.\ 

785""") 

786 

787 

788@pytest.mark.respx 1abcdefg

789def test_gets_app_as_json( 1abcdefg

790 logged_in_cli: None, 

791 respx_mock: respx.MockRouter, 

792) -> None: 

793 app_data = { 2EbFbGbHbIbJbKb

794 "id": "00000000-0000-4000-8000-000000000002", 

795 "team_id": "00000000-0000-4000-8000-000000000001", 

796 "slug": "api", 

797 "name": "API", 

798 "directory": "backend", 

799 "url": "https://api.fastapicloud.app", 

800 } 

801 respx_mock.get(f"/apps/{app_data['id']}").mock( 2EbFbGbHbIbJbKb

802 return_value=Response(200, json=app_data) 

803 ) 

804 

805 result = runner.invoke(app, ["apps", "get", app_data["id"], "--json"]) 2EbFbGbHbIbJbKb

806 

807 assert result.exit_code == 0 2EbFbGbHbIbJbKb

808 assert json.loads(result.stdout) == { 2EbFbGbHbIbJbKb

809 "data": { 

810 "app": { 

811 **app_data, 

812 "region": None, 

813 "updated_at": None, 

814 } 

815 } 

816 } 

817 assert result.stderr == "" 2EbFbGbHbIbJbKb

818 

819 

820@pytest.mark.respx 1abcdefg

821def test_gets_app_in_human_output( 1abcdefg

822 logged_in_cli: None, 

823 respx_mock: respx.MockRouter, 

824) -> None: 

825 team_data = { 2cbdbebfbgbhbib

826 "id": "00000000-0000-4000-8000-000000000001", 

827 "slug": "strawberry", 

828 "name": "Strawberry", 

829 } 

830 app_data = { 2cbdbebfbgbhbib

831 "id": "00000000-0000-4000-8000-000000000002", 

832 "team_id": team_data["id"], 

833 "slug": "api", 

834 "name": "API", 

835 "directory": "backend", 

836 "url": "https://api.fastapicloud.app", 

837 } 

838 respx_mock.get(f"/apps/{app_data['id']}").mock( 2cbdbebfbgbhbib

839 return_value=Response(200, json=app_data) 

840 ) 

841 respx_mock.get(f"/teams/{team_data['id']}").mock( 2cbdbebfbgbhbib

842 return_value=Response(200, json=team_data) 

843 ) 

844 

845 result = runner.invoke(app, ["apps", "get", app_data["id"]]) 2cbdbebfbgbhbib

846 

847 assert result.exit_code == 0 2cbdbebfbgbhbib

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

849📦 API 

850 

851 id 00000000-0000-4000-8000-000000000002 

852 slug api 

853 directory backend 

854 url https://api.fastapicloud.app 

855 dashboard https://dashboard.fastapicloud.com/strawberry/apps/api 

856 team id 00000000-0000-4000-8000-000000000001\ 

857""") 

858 

859 

860@pytest.mark.respx 1abcdefg

861def test_gets_app_json_returns_not_found_for_unknown_app( 1abcdefg

862 logged_in_cli: None, 

863 respx_mock: respx.MockRouter, 

864) -> None: 

865 app_id = "00000000-0000-4000-8000-000000000002" 2LbMbNbObPbQbRb

866 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(404)) 2LbMbNbObPbQbRb

867 

868 result = runner.invoke(app, ["apps", "get", app_id, "--json"]) 2LbMbNbObPbQbRb

869 

870 assert result.exit_code == 1 2LbMbNbObPbQbRb

871 assert json.loads(result.stdout) == { 2LbMbNbObPbQbRb

872 "error": { 

873 "code": "not_found", 

874 "message": "App not found.", 

875 "hint": None, 

876 } 

877 } 

878 assert result.stderr == "" 2LbMbNbObPbQbRb

879 

880 

881@pytest.mark.respx 1abcdefg

882def test_gets_app_as_json_uses_linked_app_when_id_omitted( 1abcdefg

883 logged_in_cli: None, 

884 respx_mock: respx.MockRouter, 

885 configured_app: ConfiguredApp, 

886) -> None: 

887 app_data = { 2jbkblbmbnbobpb

888 "id": configured_app.app_id, 

889 "team_id": configured_app.team_id, 

890 "slug": "api", 

891 "name": "API", 

892 "directory": "backend", 

893 "url": "https://api.fastapicloud.app", 

894 } 

895 respx_mock.get(f"/apps/{configured_app.app_id}").mock( 2jbkblbmbnbobpb

896 return_value=Response(200, json=app_data) 

897 ) 

898 

899 with changing_dir(configured_app.path): 2jbkblbmbnbobpb

900 result = runner.invoke(app, ["apps", "get", "--json"]) 2jbkblbmbnbobpb

901 

902 assert result.exit_code == 0 2jbkblbmbnbobpb

903 assert json.loads(result.stdout)["data"]["app"]["id"] == configured_app.app_id 2jbkblbmbnbobpb

904 assert result.stderr == "" 2jbkblbmbnbobpb

905 

906 

907def test_gets_app_json_returns_missing_required_input_without_app_id( 1abcdefg

908 logged_in_cli: None, 

909 tmp_path: Path, 

910) -> None: 

911 with changing_dir(tmp_path): 2SbTbUbVbWbXbYb

912 result = runner.invoke(app, ["apps", "get", "--json"]) 2SbTbUbVbWbXbYb

913 

914 assert result.exit_code == 1 2SbTbUbVbWbXbYb

915 assert json.loads(result.stdout) == { 2SbTbUbVbWbXbYb

916 "error": { 

917 "code": "missing_required_input", 

918 "message": "App ID is required.", 

919 "hint": "Pass an app ID or run `fastapi cloud apps create --link` first.", 

920 } 

921 } 

922 assert result.stderr == "" 2SbTbUbVbWbXbYb

923 

924 

925def test_get_app_dashboard_url_uses_settings_team_and_app_slugs() -> None: 1abcdefg

926 app_data = App.model_validate( 2GcHcIcJcKcLcMc

927 { 

928 "id": "00000000-0000-4000-8000-000000000002", 

929 "team_id": "00000000-0000-4000-8000-000000000001", 

930 "slug": "api", 

931 "name": "API", 

932 "directory": "backend", 

933 } 

934 ) 

935 

936 assert ( 2GcHcIcJcKcLcMc

937 _get_app_dashboard_url( 

938 app_data, 

939 team_slug="strawberry", 

940 settings=Settings(dashboard_base_url="https://dashboard.example.com"), 

941 ) 

942 == "https://dashboard.example.com/strawberry/apps/api" 

943 ) 

944 

945 

946@pytest.mark.respx 1abcdefg

947@time_machine.travel(datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc), tick=False) 1abcdefg

948def test_lists_apps_in_human_output( 1abcdefg

949 logged_in_cli: None, 

950 respx_mock: respx.MockRouter, 

951) -> None: 

952 team_id = "00000000-0000-4000-8000-000000000001" 1=?@[]^_

953 team_data = { 1=?@[]^_

954 "id": team_id, 

955 "slug": "strawberry", 

956 "name": "Strawberry", 

957 } 

958 app_data = { 1=?@[]^_

959 "id": "00000000-0000-4000-8000-000000000002", 

960 "team_id": team_id, 

961 "slug": "api", 

962 "name": "API", 

963 "directory": "backend", 

964 "url": "https://strawberryrocks.fastapicloud.dev", 

965 "region": "us-east-1", 

966 "updated_at": "2026-05-15T12:00:00Z", 

967 } 

968 respx_mock.get(f"/teams/{team_id}").mock(return_value=Response(200, json=team_data)) 1=?@[]^_

969 respx_mock.get("/apps/").mock( 1=?@[]^_

970 return_value=Response(200, json={"data": [app_data], "count": 1}) 

971 ) 

972 

973 result = runner.invoke(app, ["apps", "list", "--team-id", team_id]) 1=?@[]^_

974 

975 assert result.exit_code == 0 1=?@[]^_

976 assert result.output == snapshot("""\ 1=?@[]^_

977apps 

978 

979Name ID 

980 

981API 00000000-0000-4000-8000-000000000002\ 

982""") 

983 

984 

985@pytest.mark.respx 1abcdefg

986def test_lists_apps_in_human_output_empty( 1abcdefg

987 logged_in_cli: None, 

988 respx_mock: respx.MockRouter, 

989) -> None: 

990 team_id = "00000000-0000-4000-8000-000000000001" 2qbrbsbtbubvbwb

991 team_data = { 2qbrbsbtbubvbwb

992 "id": team_id, 

993 "slug": "strawberry", 

994 "name": "Strawberry", 

995 } 

996 respx_mock.get(f"/teams/{team_id}").mock(return_value=Response(200, json=team_data)) 2qbrbsbtbubvbwb

997 respx_mock.get( 2qbrbsbtbubvbwb

998 "/apps/", 

999 params={"team_id": team_id, "limit": "100", "skip": "0"}, 

1000 ).mock(return_value=Response(200, json={"data": [], "count": 0})) 

1001 

1002 result = runner.invoke(app, ["apps", "list", "--team-id", team_id]) 2qbrbsbtbubvbwb

1003 

1004 assert result.exit_code == 0 2qbrbsbtbubvbwb

1005 assert result.output == snapshot("""\ 2qbrbsbtbubvbwb

1006apps 

1007 

1008No apps found.\ 

1009""") 

1010 

1011 

1012def test_lists_apps_human_returns_not_logged_in_when_logged_out( 1abcdefg

1013 logged_out_cli: None, 

1014) -> None: 

1015 result = runner.invoke(app, ["apps", "list", "--team-id", "team-1"]) 2zcAcBcCcDcEcFc

1016 

1017 assert result.exit_code == 1 2zcAcBcCcDcEcFc

1018 assert result.output == snapshot("""\ 2zcAcBcCcDcEcFc

1019✗ error: No credentials found. 

1020 

1021 hint: Run `fastapi cloud login`.\ 

1022""")