Coverage for tests/test_cli_link.py: 100%
170 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 pathlib import Path 1abcdefg
3from unittest.mock import patch 1abcdefg
5import pytest 1abcdefg
6import respx 1abcdefg
7from httpx import Response 1abcdefg
8from typer.testing import CliRunner 1abcdefg
10from fastapi_cloud_cli.cli import cloud_app as app 1abcdefg
11from fastapi_cloud_cli.utils.apps import AppConfig 1abcdefg
12from tests.conftest import ConfiguredApp 1abcdefg
13from tests.utils import Keys, changing_dir 1abcdefg
15runner = CliRunner() 1abcdefg
18def test_link_json_requires_app_id(logged_in_cli: None) -> None: 1abcdefg
19 result = runner.invoke(app, ["link", "--json"]) 2` { | } ~ abbb
21 assert result.exit_code == 1 2` { | } ~ abbb
22 assert json.loads(result.stdout) == { 2` { | } ~ abbb
23 "error": {
24 "code": "missing_required_input",
25 "message": "App ID is required.",
26 "hint": "Pass an app ID to link an app.",
27 }
28 }
29 assert result.stderr == "" 2` { | } ~ abbb
32def test_link_json_returns_not_logged_in_when_logged_out(logged_out_cli: None) -> None: 1abcdefg
33 result = runner.invoke(app, ["link", "--json"]) 2cbdbebfbgbhbib
35 assert result.exit_code == 1 2cbdbebfbgbhbib
36 assert json.loads(result.stdout) == { 2cbdbebfbgbhbib
37 "error": {
38 "code": "not_logged_in",
39 "message": "No credentials found.",
40 "hint": "Run `fastapi cloud login`.",
41 }
42 }
43 assert result.stderr == "" 2cbdbebfbgbhbib
46@pytest.mark.respx 1abcdefg
47def test_link_direct_as_json( 1abcdefg
48 logged_in_cli: None,
49 respx_mock: respx.MockRouter,
50 tmp_path: Path,
51) -> None:
52 team_id = "00000000-0000-4000-8000-000000000001" 1EFGHIJK
53 app_id = "00000000-0000-4000-8000-000000000002" 1EFGHIJK
54 path_to_link = tmp_path / "repo" 1EFGHIJK
55 path_to_link.mkdir() 1EFGHIJK
56 app_data = { 1EFGHIJK
57 "id": app_id,
58 "team_id": team_id,
59 "slug": "api",
60 "name": "API",
61 "directory": None,
62 }
63 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 1EFGHIJK
65 result = runner.invoke( 1EFGHIJK
66 app,
67 [
68 "link",
69 app_id,
70 "--path",
71 str(path_to_link),
72 "--json",
73 ],
74 )
76 assert result.exit_code == 0 1EFGHIJK
77 assert json.loads(result.stdout) == { 1EFGHIJK
78 "data": {
79 "app_id": app_id,
80 "team_id": team_id,
81 "path": str(path_to_link),
82 }
83 }
84 assert AppConfig.model_validate_json( 1EFGHIJK
85 (path_to_link / ".fastapicloud" / "cloud.json").read_text(encoding="utf-8")
86 ) == AppConfig(app_id=app_id, team_id=team_id)
87 assert result.stderr == "" 1EFGHIJK
90@pytest.mark.respx 1abcdefg
91def test_link_direct_in_human_output( 1abcdefg
92 logged_in_cli: None,
93 respx_mock: respx.MockRouter,
94 tmp_path: Path,
95) -> None:
96 team_id = "00000000-0000-4000-8000-000000000001" 1lmnopqr
97 app_id = "00000000-0000-4000-8000-000000000002" 1lmnopqr
98 path_to_link = tmp_path / "repo" 1lmnopqr
99 path_to_link.mkdir() 1lmnopqr
100 app_data = { 1lmnopqr
101 "id": app_id,
102 "team_id": team_id,
103 "slug": "api",
104 "name": "API",
105 "directory": None,
106 }
107 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 1lmnopqr
109 result = runner.invoke( 1lmnopqr
110 app,
111 [
112 "link",
113 app_id,
114 "--path",
115 str(path_to_link),
116 ],
117 )
119 assert result.exit_code == 0 1lmnopqr
120 assert "Linked" in result.output 1lmnopqr
121 assert "API" in result.output 1lmnopqr
122 assert "Config:" in result.output 1lmnopqr
123 assert "cloud.json" in result.output 1lmnopqr
124 assert AppConfig.model_validate_json( 1lmnopqr
125 (path_to_link / ".fastapicloud" / "cloud.json").read_text(encoding="utf-8")
126 ) == AppConfig(app_id=app_id, team_id=team_id)
129@pytest.mark.respx 1abcdefg
130def test_link_direct_with_app_id_option_as_json( 1abcdefg
131 logged_in_cli: None,
132 respx_mock: respx.MockRouter,
133 tmp_path: Path,
134) -> None:
135 team_id = "00000000-0000-4000-8000-000000000001" 1LMNOPQR
136 app_id = "00000000-0000-4000-8000-000000000002" 1LMNOPQR
137 path_to_link = tmp_path / "repo" 1LMNOPQR
138 path_to_link.mkdir() 1LMNOPQR
139 app_data = { 1LMNOPQR
140 "id": app_id,
141 "team_id": team_id,
142 "slug": "api",
143 "name": "API",
144 "directory": None,
145 }
146 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 1LMNOPQR
148 result = runner.invoke( 1LMNOPQR
149 app,
150 [
151 "link",
152 "--app-id",
153 app_id,
154 "--path",
155 str(path_to_link),
156 "--json",
157 ],
158 )
160 assert result.exit_code == 0 1LMNOPQR
161 assert json.loads(result.stdout) == { 1LMNOPQR
162 "data": {
163 "app_id": app_id,
164 "team_id": team_id,
165 "path": str(path_to_link),
166 }
167 }
168 assert AppConfig.model_validate_json( 1LMNOPQR
169 (path_to_link / ".fastapicloud" / "cloud.json").read_text(encoding="utf-8")
170 ) == AppConfig(app_id=app_id, team_id=team_id)
171 assert result.stderr == "" 1LMNOPQR
174def test_link_returns_invalid_input_when_app_id_is_ambiguous( 1abcdefg
175 logged_in_cli: None,
176) -> None:
177 result = runner.invoke( 2jbkblbmbnbobpb
178 app,
179 [
180 "link",
181 "00000000-0000-4000-8000-000000000002",
182 "--app-id",
183 "00000000-0000-4000-8000-000000000003",
184 "--json",
185 ],
186 )
188 assert result.exit_code == 1 2jbkblbmbnbobpb
189 assert json.loads(result.stdout) == { 2jbkblbmbnbobpb
190 "error": {
191 "code": "invalid_input",
192 "message": "App ID was provided more than once.",
193 "hint": "Pass either APP_ID or --app-id, not both.",
194 }
195 }
196 assert result.stderr == "" 2jbkblbmbnbobpb
199def test_shows_a_message_if_not_logged_in(logged_out_cli: None) -> None: 1abcdefg
200 result = runner.invoke(app, ["link"]) 2xbybzbAbBbCbDb
202 assert result.exit_code == 1 2xbybzbAbBbCbDb
203 assert "You need to be logged in to link an app." in result.output 2xbybzbAbBbCbDb
206def test_shows_a_message_if_already_linked( 1abcdefg
207 logged_in_cli: None, configured_app: ConfiguredApp
208) -> None:
209 with changing_dir(configured_app.path): 2qbrbsbtbubvbwb
210 result = runner.invoke(app, ["link"]) 2qbrbsbtbubvbwb
212 assert result.exit_code == 1 2qbrbsbtbubvbwb
213 assert "This directory is already linked to an app." in result.output 2qbrbsbtbubvbwb
216@pytest.mark.respx 1abcdefg
217def test_shows_a_message_if_no_teams( 1abcdefg
218 logged_in_cli: None, respx_mock: respx.MockRouter, tmp_path: Path
219) -> None:
220 respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": []})) 1+,-./:;
222 with changing_dir(tmp_path): 1+,-./:;
223 result = runner.invoke(app, ["link"]) 1+,-./:;
225 assert result.exit_code == 1 1+,-./:;
226 assert "No teams found" in result.output 1+,-./:;
229@pytest.mark.respx 1abcdefg
230def test_shows_a_message_if_no_apps( 1abcdefg
231 logged_in_cli: None, respx_mock: respx.MockRouter, tmp_path: Path
232) -> None:
233 steps = [Keys.ENTER] 1489!5#$
235 respx_mock.get("/teams/").mock( 1489!5#$
236 return_value=Response(
237 200, json={"data": [{"id": "team-1", "name": "My Team", "slug": "my-team"}]}
238 )
239 )
240 respx_mock.get("/apps/", params={"team_id": "team-1"}).mock( 1489!5#$
241 return_value=Response(200, json={"data": []})
242 )
244 with ( 145
245 changing_dir(tmp_path),
246 patch("rich_toolkit.container.getchar") as mock_getchar,
247 ):
248 mock_getchar.side_effect = steps 1489!5#$
249 result = runner.invoke(app, ["link"]) 1489!5#$
251 assert result.exit_code == 1 1489!5#$
252 assert "No apps found in this team." in result.output 1489!5#$
255@pytest.mark.respx 1abcdefg
256def test_links_successfully( 1abcdefg
257 logged_in_cli: None, respx_mock: respx.MockRouter, tmp_path: Path
258) -> None:
259 steps = [Keys.ENTER, Keys.ENTER] 1hstuivw
261 respx_mock.get("/teams/").mock( 1hstuivw
262 return_value=Response(
263 200, json={"data": [{"id": "team-1", "name": "My Team", "slug": "my-team"}]}
264 )
265 )
266 respx_mock.get("/apps/", params={"team_id": "team-1"}).mock( 1hstuivw
267 return_value=Response(200, json={"data": [{"id": "app-1", "slug": "my-app"}]})
268 )
270 with ( 1hi
271 changing_dir(tmp_path),
272 patch("rich_toolkit.container.getchar") as mock_getchar,
273 ):
274 mock_getchar.side_effect = steps 1hstuivw
275 result = runner.invoke(app, ["link"]) 1hstuivw
277 assert result.exit_code == 0 1hstuivw
278 assert "Successfully linked to app" in result.output 1hstuivw
279 assert "my-app" in result.output 1hstuivw
281 config_path = tmp_path / ".fastapicloud" / "cloud.json" 1hstuivw
282 assert config_path.exists() 1hstuivw
283 config = AppConfig.model_validate_json(config_path.read_text()) 1hstuivw
284 assert config.app_id == "app-1" 1hstuivw
285 assert config.team_id == "team-1" 1hstuivw
288@pytest.mark.respx 1abcdefg
289def test_shows_error_on_teams_api_failure( 1abcdefg
290 logged_in_cli: None, respx_mock: respx.MockRouter, tmp_path: Path
291) -> None:
292 respx_mock.get("/teams/").mock(return_value=Response(500)) 1=?@[]^_
294 with changing_dir(tmp_path): 1=?@[]^_
295 result = runner.invoke(app, ["link"]) 1=?@[]^_
297 assert result.exit_code == 1 1=?@[]^_
298 assert "Error fetching teams" in result.output 1=?@[]^_
301@pytest.mark.respx 1abcdefg
302def test_shows_error_on_apps_api_failure( 1abcdefg
303 logged_in_cli: None, respx_mock: respx.MockRouter, tmp_path: Path
304) -> None:
305 steps = [Keys.ENTER] 16%'(7)*
307 respx_mock.get("/teams/").mock( 16%'(7)*
308 return_value=Response(
309 200, json={"data": [{"id": "team-1", "name": "My Team", "slug": "my-team"}]}
310 )
311 )
312 respx_mock.get("/apps/", params={"team_id": "team-1"}).mock( 16%'(7)*
313 return_value=Response(500)
314 )
316 with ( 167
317 changing_dir(tmp_path),
318 patch("rich_toolkit.container.getchar") as mock_getchar,
319 ):
320 mock_getchar.side_effect = steps 16%'(7)*
321 result = runner.invoke(app, ["link"]) 16%'(7)*
323 assert result.exit_code == 1 16%'(7)*
324 assert "Error fetching apps" in result.output 16%'(7)*
327@pytest.mark.respx 1abcdefg
328def test_app_menu_is_ordered_by_slug( 1abcdefg
329 logged_in_cli: None, respx_mock: respx.MockRouter, tmp_path: Path
330) -> None:
331 steps = [Keys.ENTER, Keys.ENTER] 1CSTUDVW
333 respx_mock.get("/teams/").mock( 1CSTUDVW
334 return_value=Response(
335 200, json={"data": [{"id": "team-1", "name": "My Team", "slug": "my-team"}]}
336 )
337 )
338 respx_mock.get("/apps/", params={"team_id": "team-1"}).mock( 1CSTUDVW
339 return_value=Response(
340 200,
341 json={
342 "data": [
343 {"id": "app-2", "slug": "zebra"},
344 {"id": "app-1", "slug": "alpha"},
345 ]
346 },
347 )
348 )
350 with ( 1CD
351 changing_dir(tmp_path),
352 patch("rich_toolkit.container.getchar") as mock_getchar,
353 ):
354 mock_getchar.side_effect = steps 1CSTUDVW
355 result = runner.invoke(app, ["link"]) 1CSTUDVW
357 assert result.exit_code == 0 1CSTUDVW
358 assert "Successfully linked to app" in result.output 1CSTUDVW
359 assert "alpha" in result.output 1CSTUDVW
361 config_path = tmp_path / ".fastapicloud" / "cloud.json" 1CSTUDVW
362 config = AppConfig.model_validate_json(config_path.read_text()) 1CSTUDVW
363 assert config.app_id == "app-1" 1CSTUDVW
366@pytest.mark.respx 1abcdefg
367def test_team_menu_is_ordered_by_name( 1abcdefg
368 logged_in_cli: None, respx_mock: respx.MockRouter, tmp_path: Path
369) -> None:
370 steps = [Keys.ENTER, Keys.ENTER] 1XZ01Y23
372 respx_mock.get("/teams/").mock( 1XZ01Y23
373 return_value=Response(
374 200,
375 json={
376 "data": [
377 {"id": "team-2", "name": "Zebra Team", "slug": "zebra-team"},
378 {"id": "team-1", "name": "Alpha Team", "slug": "alpha-team"},
379 ]
380 },
381 )
382 )
383 respx_mock.get("/apps/", params={"team_id": "team-1"}).mock( 1XZ01Y23
384 return_value=Response(200, json={"data": [{"id": "app-1", "slug": "my-app"}]})
385 )
387 with ( 1XY
388 changing_dir(tmp_path),
389 patch("rich_toolkit.container.getchar") as mock_getchar,
390 ):
391 mock_getchar.side_effect = steps 1XZ01Y23
392 result = runner.invoke(app, ["link"]) 1XZ01Y23
394 assert result.exit_code == 0 1XZ01Y23
395 assert "Successfully linked to app" in result.output 1XZ01Y23
397 config_path = tmp_path / ".fastapicloud" / "cloud.json" 1XZ01Y23
398 config = AppConfig.model_validate_json(config_path.read_text()) 1XZ01Y23
399 assert config.team_id == "team-1" 1XZ01Y23
402@pytest.mark.respx 1abcdefg
403def test_links_with_multiple_teams_and_apps( 1abcdefg
404 logged_in_cli: None, respx_mock: respx.MockRouter, tmp_path: Path
405) -> None:
406 steps = [Keys.DOWN_ARROW, Keys.ENTER, Keys.DOWN_ARROW, Keys.ENTER] 1jxyzkAB
408 respx_mock.get("/teams/").mock( 1jxyzkAB
409 return_value=Response(
410 200,
411 json={
412 "data": [
413 {"id": "team-1", "name": "Team One", "slug": "team-one"},
414 {"id": "team-2", "name": "Team Two", "slug": "team-two"},
415 ]
416 },
417 )
418 )
419 respx_mock.get("/apps/", params={"team_id": "team-2"}).mock( 1jxyzkAB
420 return_value=Response(
421 200,
422 json={
423 "data": [
424 {"id": "app-1", "slug": "first-app"},
425 {"id": "app-2", "slug": "second-app"},
426 ]
427 },
428 )
429 )
431 with ( 1jk
432 changing_dir(tmp_path),
433 patch("rich_toolkit.container.getchar") as mock_getchar,
434 ):
435 mock_getchar.side_effect = steps 1jxyzkAB
436 result = runner.invoke(app, ["link"]) 1jxyzkAB
438 assert result.exit_code == 0 1jxyzkAB
439 assert "Successfully linked to app" in result.output 1jxyzkAB
440 assert "second-app" in result.output 1jxyzkAB
442 config_path = tmp_path / ".fastapicloud" / "cloud.json" 1jxyzkAB
443 assert config_path.exists() 1jxyzkAB
444 config = AppConfig.model_validate_json(config_path.read_text()) 1jxyzkAB
445 assert config.app_id == "app-2" 1jxyzkAB
446 assert config.team_id == "team-2" 1jxyzkAB