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
« 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
6import pytest 1abcdefg
7import respx 1abcdefg
8import time_machine 1abcdefg
9from httpx import Response 1abcdefg
10from inline_snapshot import snapshot 1abcdefg
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
22runner = SnapshotCliRunner() 1abcdefg
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 )
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
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))
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 )
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
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))
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 )
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
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))
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
187 assert result.exit_code == 0 1CZ01D23
188 assert result.output == snapshot("""\ 1CZ01D23
189Select the team:
190Filter:
192● Acme
194Select the team: Acme
196Created app API\
197""")
198 assert not (tmp_path / ".fastapicloud" / "cloud.json").exists() 1CZ01D23
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))
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 )
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 )
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
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
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
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))
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 )
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
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 )
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
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 )
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
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))
406 result = runner.invoke( 2` { | } ~ abbb
407 app,
408 [
409 "apps",
410 "update",
411 app_id,
412 "--directory",
413 "backend",
414 "--json",
415 ],
416 )
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
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))
441 with changing_dir(configured_app.path): 2xbybzbAbBbCbDb
442 result = runner.invoke(app, ["apps", "update", "--directory", "src"]) 2xbybzbAbBbCbDb
444 assert result.exit_code == 0 2xbybzbAbBbCbDb
445 assert result.output == snapshot("""\ 2xbybzbAbBbCbDb
446Updated app API
447Directory: src\
448""")
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 )
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
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 )
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
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
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 )
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
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 )
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 )
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!
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
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 )
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
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}))
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 )
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#$%'()*
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
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
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 )
715 assert result.exit_code == 1 2sctcucvcwcxcyc
716 assert result.output == snapshot("""\ 2sctcucvcwcxcyc
717✗ error: No credentials found.
719 hint: Run `fastapi cloud login`.\
720""")
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 )
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+,-./:;
751 assert result.exit_code == 0 1+,-./:;
752 assert result.output == snapshot("""\ 1+,-./:;
753Select the team:
754Filter:
756● Acme
758Select the team: Acme
760apps
762Name ID
764API 00000000-0000-4000-8000-000000000002\
765""")
766 assert apps_route.calls.last.request.url.params["team_id"] == team["id"] 1+,-./:;
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 )
778 result = runner.invoke(app, ["apps", "list"]) 2lcmcncocpcqcrc
780 assert result.exit_code == 1 2lcmcncocpcqcrc
781 assert result.output == snapshot("""\ 2lcmcncocpcqcrc
782✗ error: No teams found.
784 hint: Create a team before listing apps.\
785""")
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 )
805 result = runner.invoke(app, ["apps", "get", app_data["id"], "--json"]) 2EbFbGbHbIbJbKb
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
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 )
845 result = runner.invoke(app, ["apps", "get", app_data["id"]]) 2cbdbebfbgbhbib
847 assert result.exit_code == 0 2cbdbebfbgbhbib
848 assert result.output == snapshot("""\ 2cbdbebfbgbhbib
849📦 API
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""")
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
868 result = runner.invoke(app, ["apps", "get", app_id, "--json"]) 2LbMbNbObPbQbRb
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
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 )
899 with changing_dir(configured_app.path): 2jbkblbmbnbobpb
900 result = runner.invoke(app, ["apps", "get", "--json"]) 2jbkblbmbnbobpb
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
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
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
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 )
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 )
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 )
973 result = runner.invoke(app, ["apps", "list", "--team-id", team_id]) 1=?@[]^_
975 assert result.exit_code == 0 1=?@[]^_
976 assert result.output == snapshot("""\ 1=?@[]^_
977apps
979Name ID
981API 00000000-0000-4000-8000-000000000002\
982""")
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}))
1002 result = runner.invoke(app, ["apps", "list", "--team-id", team_id]) 2qbrbsbtbubvbwb
1004 assert result.exit_code == 0 2qbrbsbtbubvbwb
1005 assert result.output == snapshot("""\ 2qbrbsbtbubvbwb
1006apps
1008No apps found.\
1009""")
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
1017 assert result.exit_code == 1 2zcAcBcCcDcEcFc
1018 assert result.output == snapshot("""\ 2zcAcBcCcDcEcFc
1019✗ error: No credentials found.
1021 hint: Run `fastapi cloud login`.\
1022""")