Coverage for tests/test_env_delete.py: 100%
122 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 inline_snapshot import snapshot 1abcdefg
10from fastapi_cloud_cli.cli import cloud_app as app 1abcdefg
11from tests.utils import Keys, SnapshotCliRunner, changing_dir 1abcdefg
13runner = SnapshotCliRunner() 1abcdefg
16@pytest.mark.respx 1abcdefg
17def test_delete_succeeds_when_variable_is_already_absent( 1abcdefg
18 logged_in_cli: None, respx_mock: respx.MockRouter
19) -> None:
20 respx_mock.put( 1jklmnop
21 "/apps/123/environment-variables/",
22 json={"upsert": {}, "delete": ["TOKEN"], "redeploy": True},
23 ).mock(return_value=Response(200, json={"data": [], "count": 0}))
25 for _ in range(2): 1jklmnop
26 result = runner.invoke( 1jklmnop
27 app, ["env", "delete", "TOKEN", "--app-id", "123", "--yes", "--json"]
28 )
30 assert result.exit_code == 0 1jklmnop
31 assert json.loads(result.stdout) == { 1jklmnop
32 "data": {"app_id": "123", "name": "TOKEN", "deleted": True}
33 }
34 assert result.stderr == "" 1jklmnop
37@pytest.mark.respx 1abcdefg
38def test_delete_json_reports_managed_variable_error( 1abcdefg
39 logged_in_cli: None, respx_mock: respx.MockRouter
40) -> None:
41 message = ( 1qrstuvw
42 "Cannot modify integration-managed environment variables: 'DATABASE_URL'. "
43 "Disconnect the associated integration resources to remove these variables."
44 )
45 respx_mock.put( 1qrstuvw
46 "/apps/123/environment-variables/",
47 json={"upsert": {}, "delete": ["DATABASE_URL"], "redeploy": True},
48 ).mock(return_value=Response(400, json={"detail": message}))
50 result = runner.invoke( 1qrstuvw
51 app, ["env", "delete", "DATABASE_URL", "--app-id", "123", "--yes", "--json"]
52 )
54 assert result.exit_code == 1 1qrstuvw
55 assert json.loads(result.stdout) == { 1qrstuvw
56 "error": {"code": "invalid_input", "message": message, "hint": None}
57 }
58 assert result.stderr == "" 1qrstuvw
61assets_path = Path(__file__).parent / "assets" 1abcdefg
64@pytest.fixture 1abcdefg
65def configured_app(tmp_path: Path) -> Path: 1abcdefg
66 app_id = "123" 1abcdefg
67 team_id = "456" 1abcdefg
69 config_path = tmp_path / ".fastapicloud" / "cloud.json" 1abcdefg
71 config_path.parent.mkdir(parents=True, exist_ok=True) 1abcdefg
72 config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}') 1abcdefg
74 return tmp_path 1abcdefg
77def test_shows_a_message_if_not_logged_in(logged_out_cli: None) -> None: 1abcdefg
78 result = runner.invoke(app, ["env", "delete"]) 2lbmbnbobpbqbrb
80 assert result.exit_code == 1 2lbmbnbobpbqbrb
81 assert "No credentials found." in result.output 2lbmbnbobpbqbrb
84def test_shows_a_message_if_app_is_not_configured(logged_in_cli: None) -> None: 1abcdefg
85 result = runner.invoke(app, ["env", "delete"]) 2sbtbubvbwbxbyb
87 assert result.exit_code == 1 2sbtbubvbwbxbyb
88 assert "App ID is required." in result.output 2sbtbubvbwbxbyb
91def test_delete_json_returns_missing_required_input_without_app_context( 1abcdefg
92 logged_in_cli: None,
93) -> None:
94 result = runner.invoke(app, ["env", "delete", "DATABASE_URL", "--yes", "--json"]) 1%'()*+,
96 assert result.exit_code == 1 1%'()*+,
97 assert json.loads(result.stdout) == { 1%'()*+,
98 "error": {
99 "code": "missing_required_input",
100 "message": "App ID is required.",
101 "hint": "Pass --app-id or run `fastapi cloud apps create --link` first.",
102 }
103 }
104 assert result.stderr == "" 1%'()*+,
107@pytest.mark.respx 1abcdefg
108def test_shows_a_message_if_something_is_wrong( 1abcdefg
109 logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: Path
110) -> None:
111 respx_mock.put( 1QRSTUVW
112 "/apps/123/environment-variables/",
113 json={"upsert": {}, "delete": ["SOME_VAR"], "redeploy": True},
114 ).mock(return_value=Response(500))
116 with changing_dir(configured_app): 1QRSTUVW
117 result = runner.invoke(app, ["env", "delete", "SOME_VAR", "--yes"]) 1QRSTUVW
119 assert result.exit_code == 1 1QRSTUVW
120 assert ( 1QRSTUVW
121 "Something went wrong while contacting the FastAPI Cloud server."
122 in result.output
123 )
126@pytest.mark.respx 1abcdefg
127def test_shows_message_if_app_not_found( 1abcdefg
128 logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: Path
129) -> None:
130 respx_mock.put( 1XYZ0123
131 "/apps/123/environment-variables/",
132 json={"upsert": {}, "delete": ["SOME_VAR"], "redeploy": True},
133 ).mock(return_value=Response(404, json={"detail": "App not found"}))
135 with changing_dir(configured_app): 1XYZ0123
136 result = runner.invoke(app, ["env", "delete", "SOME_VAR", "--yes"]) 1XYZ0123
138 assert result.exit_code == 1 1XYZ0123
139 assert "App not found" in result.output 1XYZ0123
142def test_shows_a_message_if_name_is_invalid( 1abcdefg
143 logged_in_cli: None, configured_app: Path
144) -> None:
145 with changing_dir(configured_app): 1-./:;=?
146 result = runner.invoke(app, ["env", "delete", "aaa-aaa"]) 1-./:;=?
148 assert result.exit_code == 1 1-./:;=?
149 assert "The environment variable name aaa-aaa is invalid." in result.output 1-./:;=?
152@pytest.mark.respx 1abcdefg
153def test_shows_message_when_it_deletes( 1abcdefg
154 logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: Path
155) -> None:
156 respx_mock.put( 14@[]5^_
157 "/apps/123/environment-variables/",
158 json={"upsert": {}, "delete": ["SOME_VAR"], "redeploy": True},
159 ).mock(return_value=Response(200, json={"data": [], "count": 0}))
161 with ( 145
162 changing_dir(configured_app),
163 patch(
164 "rich_toolkit.container.getchar", side_effect=[Keys.RIGHT_ARROW, Keys.ENTER]
165 ),
166 ):
167 result = runner.invoke(app, ["env", "delete", "SOME_VAR"]) 14@[]5^_
169 assert result.exit_code == 0 14@[]5^_
170 assert result.output == snapshot("""\ 14@[]5^_
171Delete SOME_VAR?
172○ Yes ● No
174Delete SOME_VAR?
175● Yes ○ No
177Delete SOME_VAR? Yes
179environment variables
181Environment variable SOME_VAR deleted.\
182""")
185@pytest.mark.respx 1abcdefg
186@pytest.mark.parametrize("no_redeploy", [False, True]) 1abcdefg
187def test_deletes_environment_variable_as_json_with_app_id( 1abcdefg
188 logged_in_cli: None, respx_mock: respx.MockRouter, no_redeploy: bool
189) -> None:
190 app_id = "00000000-0000-4000-8000-000000000002" 1xyzABCD
191 respx_mock.put( 1xyzABCD
192 f"/apps/{app_id}/environment-variables/",
193 json={"upsert": {}, "delete": ["DATABASE_URL"], "redeploy": not no_redeploy},
194 ).mock(return_value=Response(200, json={"data": [], "count": 0}))
196 result = runner.invoke( 1xyzABCD
197 app,
198 [
199 "env",
200 "delete",
201 "DATABASE_URL",
202 "--app-id",
203 app_id,
204 "--yes",
205 "--json",
206 *(["--no-redeploy"] if no_redeploy else []),
207 ],
208 )
210 assert result.exit_code == 0 1xyzABCD
211 assert json.loads(result.stdout) == { 1xyzABCD
212 "data": {
213 "app_id": app_id,
214 "name": "DATABASE_URL",
215 "deleted": True,
216 }
217 }
218 assert result.stderr == "" 1xyzABCD
221@pytest.mark.respx 1abcdefg
222def test_delete_environment_variable_json_returns_app_not_found( 1abcdefg
223 logged_in_cli: None, respx_mock: respx.MockRouter
224) -> None:
225 app_id = "00000000-0000-4000-8000-000000000002" 1EFGHIJK
226 respx_mock.put( 1EFGHIJK
227 f"/apps/{app_id}/environment-variables/",
228 json={"upsert": {}, "delete": ["DATABASE_URL"], "redeploy": True},
229 ).mock(return_value=Response(404, json={"detail": "App not found"}))
231 result = runner.invoke( 1EFGHIJK
232 app,
233 [
234 "env",
235 "delete",
236 "DATABASE_URL",
237 "--app-id",
238 app_id,
239 "--yes",
240 "--json",
241 ],
242 )
244 assert result.exit_code == 1 1EFGHIJK
245 assert json.loads(result.stdout) == { 1EFGHIJK
246 "error": {
247 "code": "not_found",
248 "message": "App not found",
249 "hint": None,
250 }
251 }
252 assert result.stderr == "" 1EFGHIJK
255@pytest.mark.respx 1abcdefg
256def test_shows_selector_for_environment_variables( 1abcdefg
257 logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: Path
258) -> None:
259 steps = [Keys.ENTER] 1hLMNiOP
260 respx_mock.get("/apps/123/environment-variables/").mock( 1hLMNiOP
261 return_value=Response(
262 200,
263 json={
264 "data": [
265 {"name": "SECRET_KEY", "value": "123"},
266 {"name": "API_KEY", "value": "456"},
267 ]
268 },
269 )
270 )
272 respx_mock.put( 1hLMNiOP
273 "/apps/123/environment-variables/",
274 json={"upsert": {}, "delete": ["SECRET_KEY"], "redeploy": True},
275 ).mock(return_value=Response(200, json={"data": [], "count": 0}))
277 with ( 1hi
278 changing_dir(configured_app),
279 patch("rich_toolkit.container.getchar", side_effect=steps),
280 ):
281 result = runner.invoke(app, ["env", "delete"]) 1hLMNiOP
283 assert result.exit_code == 0 1hLMNiOP
284 assert result.output == snapshot("""\ 1hLMNiOP
285environment variables
287Select the environment variable to delete:
288● SECRET_KEY
289○ API_KEY
291Select the environment variable to delete: SECRET_KEY
293Environment variable SECRET_KEY deleted.\
294""")
297@pytest.mark.respx 1abcdefg
298def test_shows_message_if_no_environment_variable( 1abcdefg
299 logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: Path
300) -> None:
301 respx_mock.get("/apps/123/environment-variables/").mock( 16789!#$
302 return_value=Response(200, json={"data": []})
303 )
305 with changing_dir(configured_app): 16789!#$
306 result = runner.invoke(app, ["env", "delete"]) 16789!#$
308 assert result.exit_code == 0 16789!#$
309 assert result.output == snapshot("""\ 16789!#$
310environment variables
312No environment variables found.\
313""")
316def test_delete_json_returns_missing_required_input_without_name( 1abcdefg
317 logged_in_cli: None,
318) -> None:
319 result = runner.invoke( 2` { | } ~ abbb
320 app,
321 ["env", "delete", "--app-id", "00000000-0000-4000-8000-000000000002", "--json"],
322 )
324 assert result.exit_code == 1 2` { | } ~ abbb
325 assert json.loads(result.stdout) == { 2` { | } ~ abbb
326 "error": {
327 "code": "missing_required_input",
328 "message": "Environment variable name is required.",
329 "hint": "Pass NAME to choose an environment variable.",
330 }
331 }
332 assert result.stderr == "" 2` { | } ~ abbb
335def test_delete_json_returns_missing_required_input_without_confirmation( 1abcdefg
336 logged_in_cli: None,
337) -> None:
338 result = runner.invoke( 2cbdbebfbgbhbib
339 app,
340 [
341 "env",
342 "delete",
343 "DATABASE_URL",
344 "--app-id",
345 "00000000-0000-4000-8000-000000000002",
346 "--json",
347 ],
348 )
350 assert result.exit_code == 1 2cbdbebfbgbhbib
351 assert json.loads(result.stdout) == { 2cbdbebfbgbhbib
352 "error": {
353 "code": "missing_required_input",
354 "message": "Deletion confirmation is required.",
355 "hint": "Pass --yes to confirm deletion.",
356 }
357 }
358 assert result.stderr == "" 2cbdbebfbgbhbib
361def test_shows_message_when_deletion_is_cancelled( 1abcdefg
362 logged_in_cli: None, configured_app: Path
363) -> None:
364 with ( 2jbkb
365 changing_dir(configured_app),
366 patch(
367 "rich_toolkit.container.getchar",
368 side_effect=[Keys.ENTER],
369 ),
370 ):
371 result = runner.invoke(app, ["env", "delete", "SOME_VAR"]) 2jbzbAbBbkbCbDb
373 assert result.exit_code == 0 2jbzbAbBbkbCbDb
374 assert result.output == snapshot("""\ 2jbzbAbBbkbCbDb
375Delete SOME_VAR?
376○ Yes ● No
378Delete SOME_VAR? No
379environment variables
381Deletion cancelled.\
382""")