Coverage for tests/integrations/resources/test_disconnect.py: 100%
84 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 fastapi_cloud_cli.utils.apps import AppConfig, write_app_config 1abcdefg
12from tests.utils import Keys, SnapshotCliRunner, changing_dir 1abcdefg
14runner = SnapshotCliRunner() 1abcdefg
16APP_ID = "00000000-0000-4000-8000-000000000001" 1abcdefg
17LINKED_APP_ID = "00000000-0000-4000-8000-000000000002" 1abcdefg
18TEAM_ID = "00000000-0000-4000-8000-000000000003" 1abcdefg
19RESOURCE_ID = "00000000-0000-4000-8000-000000000004" 1abcdefg
20SECOND_RESOURCE_ID = "00000000-0000-4000-8000-000000000005" 1abcdefg
21INTEGRATION_ID = "00000000-0000-4000-8000-000000000006" 1abcdefg
24def _resource_summary( 1abcdefg
25 *,
26 resource_id: str = RESOURCE_ID,
27 name: str = "production",
28 provider: str = "neon",
29) -> dict[str, object]:
30 return { 1hiCvqjDwrkExslFymnGztoHAupIB
31 "id": resource_id,
32 "name": name,
33 "app_id": APP_ID,
34 "integration_id": INTEGRATION_ID,
35 "provider_metadata": {
36 "type": provider,
37 "database_name": "app_db",
38 },
39 "console_url": "https://provider.example/resource",
40 "created_at": "2026-08-18T10:00:00Z",
41 "updated_at": "2026-08-19T11:00:00Z",
42 }
45def _resource_detail() -> dict[str, object]: 1abcdefg
46 return { 1hiCvqjDwrkExslFymnGztoHAupIB
47 **_resource_summary(),
48 "provider_metadata": {
49 "type": "neon",
50 "project_id": "project-id",
51 "branch_id": "branch-id",
52 "database_name": "app_db",
53 },
54 "environment_variables": [
55 {"name": "DATABASE_URL"},
56 {"name": "DATABASE_PASSWORD"},
57 ],
58 }
61def test_disconnect_resource_json_returns_not_logged_in_when_logged_out( 1abcdefg
62 logged_out_cli: None,
63) -> None:
64 result = runner.invoke( 1QRSTUVW
65 app,
66 [
67 "integrations",
68 "resources",
69 "disconnect",
70 RESOURCE_ID,
71 "--app-id",
72 APP_ID,
73 "--yes",
74 "--json",
75 ],
76 )
78 assert result.exit_code == 1 1QRSTUVW
79 assert json.loads(result.stdout) == { 1QRSTUVW
80 "error": {
81 "code": "not_logged_in",
82 "message": "No credentials found.",
83 "hint": "Run `fastapi cloud login`.",
84 }
85 }
86 assert result.stderr == "" 1QRSTUVW
89def test_disconnect_resource_json_requires_resource_id( 1abcdefg
90 logged_in_cli: None,
91) -> None:
92 result = runner.invoke( 1XYZ0123
93 app,
94 [
95 "integrations",
96 "resources",
97 "disconnect",
98 "--app-id",
99 APP_ID,
100 "--yes",
101 "--json",
102 ],
103 )
105 assert result.exit_code == 1 1XYZ0123
106 assert json.loads(result.stdout) == { 1XYZ0123
107 "error": {
108 "code": "missing_required_input",
109 "message": "Resource ID is required.",
110 "hint": "Pass RESOURCE_ID to choose a connected resource.",
111 }
112 }
113 assert result.stderr == "" 1XYZ0123
116def test_disconnect_resource_json_requires_confirmation( 1abcdefg
117 logged_in_cli: None,
118) -> None:
119 result = runner.invoke( 1456789!
120 app,
121 [
122 "integrations",
123 "resources",
124 "disconnect",
125 RESOURCE_ID,
126 "--app-id",
127 APP_ID,
128 "--json",
129 ],
130 )
132 assert result.exit_code == 1 1456789!
133 assert json.loads(result.stdout) == { 1456789!
134 "error": {
135 "code": "missing_required_input",
136 "message": "Disconnection confirmation is required.",
137 "hint": "Pass --yes to confirm disconnection.",
138 }
139 }
140 assert result.stderr == "" 1456789!
143@pytest.mark.respx 1abcdefg
144def test_disconnect_resource_as_json_with_explicit_app_overriding_linked_app( 1abcdefg
145 logged_in_cli: None,
146 respx_mock: respx.MockRouter,
147 tmp_path: Path,
148) -> None:
149 write_app_config( 1ijklnop
150 tmp_path,
151 AppConfig(app_id=LINKED_APP_ID, team_id=TEAM_ID),
152 )
153 respx_mock.get(f"/apps/{APP_ID}/connected-resources/{RESOURCE_ID}").mock( 1ijklnop
154 return_value=Response(200, json=_resource_detail())
155 )
156 respx_mock.delete(f"/apps/{APP_ID}/connected-resources/{RESOURCE_ID}").mock( 1ijklnop
157 return_value=Response(204)
158 )
160 with changing_dir(tmp_path): 1ijklnop
161 result = runner.invoke( 1ijklnop
162 app,
163 [
164 "integrations",
165 "resources",
166 "disconnect",
167 RESOURCE_ID,
168 "--app-id",
169 APP_ID,
170 "--yes",
171 "--json",
172 ],
173 )
175 assert result.exit_code == 0 1ijklnop
176 assert json.loads(result.stdout) == { 1ijklnop
177 "data": {
178 "app_id": APP_ID,
179 "resource_id": RESOURCE_ID,
180 "disconnected": True,
181 }
182 }
183 assert result.stderr == "" 1ijklnop
186@pytest.mark.respx 1abcdefg
187def test_disconnect_resource_allows_interactive_selection( 1abcdefg
188 logged_in_cli: None,
189 respx_mock: respx.MockRouter,
190 tmp_path: Path,
191) -> None:
192 write_app_config( 1hqrsmtu
193 tmp_path,
194 AppConfig(app_id=APP_ID, team_id=TEAM_ID),
195 )
196 respx_mock.get(f"/apps/{APP_ID}/connected-resources").mock( 1hqrsmtu
197 return_value=Response(
198 200,
199 json={
200 "data": [
201 _resource_summary(),
202 _resource_summary(
203 resource_id=SECOND_RESOURCE_ID,
204 name="cache",
205 provider="redis",
206 ),
207 ],
208 "count": 2,
209 },
210 )
211 )
212 respx_mock.get(f"/apps/{APP_ID}/connected-resources/{RESOURCE_ID}").mock( 1hqrsmtu
213 return_value=Response(200, json=_resource_detail())
214 )
215 respx_mock.delete(f"/apps/{APP_ID}/connected-resources/{RESOURCE_ID}").mock( 1hqrsmtu
216 return_value=Response(204)
217 )
219 with ( 1hm
220 changing_dir(tmp_path),
221 patch(
222 "rich_toolkit.container.getchar",
223 side_effect=[Keys.ENTER, Keys.RIGHT_ARROW, Keys.ENTER],
224 ),
225 ):
226 result = runner.invoke(app, ["integrations", "resources", "disconnect"]) 1hqrsmtu
228 assert result.exit_code == 0 1hqrsmtu
229 assert result.output == snapshot("""\ 1hqrsmtu
230disconnect resource
232 Select the resource to disconnect:
233 ● production (Neon)
234 ○ cache (Redis Cloud)
236 Select the resource to disconnect: production (Neon)
238💡 The managed environment variables DATABASE_URL, DATABASE_PASSWORD will be
239 removed from the app. The Neon resource itself will not be deleted.
241 Disconnect production?
242 ○ Yes ● No
244 Disconnect production?
245 ● Yes ○ No
247 Disconnect production? Yes
249🔌 Disconnected production from the app.
251 Removed managed environment variables: DATABASE_URL, DATABASE_PASSWORD.\
252""")
255@pytest.mark.respx 1abcdefg
256def test_disconnect_resource_prompts_for_confirmation_when_id_is_provided( 1abcdefg
257 logged_in_cli: None,
258 respx_mock: respx.MockRouter,
259) -> None:
260 respx_mock.get(f"/apps/{APP_ID}/connected-resources/{RESOURCE_ID}").mock( 1vwxyzAB
261 return_value=Response(
262 200,
263 json={**_resource_detail(), "environment_variables": []},
264 )
265 )
266 respx_mock.delete(f"/apps/{APP_ID}/connected-resources/{RESOURCE_ID}").mock( 1vwxyzAB
267 return_value=Response(204)
268 )
270 with patch( 1vwxyzAB
271 "rich_toolkit.container.getchar", side_effect=[Keys.RIGHT_ARROW, Keys.ENTER]
272 ):
273 result = runner.invoke( 1vwxyzAB
274 app,
275 [
276 "integrations",
277 "resources",
278 "disconnect",
279 RESOURCE_ID,
280 "--app-id",
281 APP_ID,
282 ],
283 )
285 assert result.exit_code == 0 1vwxyzAB
286 assert result.output == snapshot("""\ 1vwxyzAB
287disconnect resource
289💡 Managed environment variables will be removed from the app. The Neon
290 resource itself will not be deleted.
292 Disconnect production?
293 ○ Yes ● No
295 Disconnect production?
296 ● Yes ○ No
298 Disconnect production? Yes
300🔌 Disconnected production from the app.\
301""")
304@pytest.mark.respx 1abcdefg
305def test_disconnect_resource_can_be_cancelled( 1abcdefg
306 logged_in_cli: None,
307 respx_mock: respx.MockRouter,
308) -> None:
309 respx_mock.get(f"/apps/{APP_ID}/connected-resources/{RESOURCE_ID}").mock( 1CDEFGHI
310 return_value=Response(200, json=_resource_detail())
311 )
313 with patch( 1CDEFGHI
314 "rich_toolkit.container.getchar",
315 side_effect=[Keys.ENTER],
316 ):
317 result = runner.invoke( 1CDEFGHI
318 app,
319 [
320 "integrations",
321 "resources",
322 "disconnect",
323 RESOURCE_ID,
324 "--app-id",
325 APP_ID,
326 ],
327 )
329 assert result.exit_code == 0 1CDEFGHI
330 assert result.output == snapshot("""\ 1CDEFGHI
331disconnect resource
333💡 The managed environment variables DATABASE_URL, DATABASE_PASSWORD will be
334 removed from the app. The Neon resource itself will not be deleted.
336 Disconnect production?
337 ○ Yes ● No
339 Disconnect production? No
341 Disconnection cancelled.\
342""")
345@pytest.mark.respx 1abcdefg
346def test_disconnect_resource_selector_handles_empty_list( 1abcdefg
347 logged_in_cli: None,
348 respx_mock: respx.MockRouter,
349) -> None:
350 respx_mock.get(f"/apps/{APP_ID}/connected-resources").mock( 1#$%'()*
351 return_value=Response(200, json={"data": [], "count": 0})
352 )
354 result = runner.invoke( 1#$%'()*
355 app,
356 ["integrations", "resources", "disconnect", "--app-id", APP_ID],
357 )
359 assert result.exit_code == 0 1#$%'()*
360 assert result.output == snapshot("""\ 1#$%'()*
361disconnect resource
363No connected resources found.\
364""")
367@pytest.mark.respx 1abcdefg
368def test_disconnect_resource_json_returns_not_found( 1abcdefg
369 logged_in_cli: None,
370 respx_mock: respx.MockRouter,
371) -> None:
372 respx_mock.get(f"/apps/{APP_ID}/connected-resources/{RESOURCE_ID}").mock( 1JKLMNOP
373 return_value=Response(404)
374 )
376 result = runner.invoke( 1JKLMNOP
377 app,
378 [
379 "integrations",
380 "resources",
381 "disconnect",
382 RESOURCE_ID,
383 "--app-id",
384 APP_ID,
385 "--yes",
386 "--json",
387 ],
388 )
390 assert result.exit_code == 1 1JKLMNOP
391 assert json.loads(result.stdout) == { 1JKLMNOP
392 "error": {
393 "code": "not_found",
394 "message": "Connected resource not found.",
395 "hint": None,
396 }
397 }
398 assert result.stderr == "" 1JKLMNOP