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

1import json 1abcdefg

2from pathlib import Path 1abcdefg

3from unittest.mock import patch 1abcdefg

4 

5import pytest 1abcdefg

6import respx 1abcdefg

7from httpx import Response 1abcdefg

8from inline_snapshot import snapshot 1abcdefg

9 

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

13 

14runner = SnapshotCliRunner() 1abcdefg

15 

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

22 

23 

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 } 

43 

44 

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 } 

59 

60 

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 ) 

77 

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

87 

88 

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 ) 

104 

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

114 

115 

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 ) 

131 

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!

141 

142 

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 ) 

159 

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 ) 

174 

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

184 

185 

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 ) 

218 

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

227 

228 assert result.exit_code == 0 1hqrsmtu

229 assert result.output == snapshot("""\ 1hqrsmtu

230disconnect resource 

231 

232 Select the resource to disconnect: 

233 ● production (Neon) 

234 ○ cache (Redis Cloud) 

235 

236 Select the resource to disconnect: production (Neon) 

237 

238💡 The managed environment variables DATABASE_URL, DATABASE_PASSWORD will be 

239 removed from the app. The Neon resource itself will not be deleted. 

240 

241 Disconnect production? 

242 ○ Yes ● No 

243 

244 Disconnect production? 

245 ● Yes ○ No 

246 

247 Disconnect production? Yes 

248 

249🔌 Disconnected production from the app. 

250 

251 Removed managed environment variables: DATABASE_URL, DATABASE_PASSWORD.\ 

252""") 

253 

254 

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 ) 

269 

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 ) 

284 

285 assert result.exit_code == 0 1vwxyzAB

286 assert result.output == snapshot("""\ 1vwxyzAB

287disconnect resource 

288 

289💡 Managed environment variables will be removed from the app. The Neon 

290 resource itself will not be deleted. 

291 

292 Disconnect production? 

293 ○ Yes ● No 

294 

295 Disconnect production? 

296 ● Yes ○ No 

297 

298 Disconnect production? Yes 

299 

300🔌 Disconnected production from the app.\ 

301""") 

302 

303 

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 ) 

312 

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 ) 

328 

329 assert result.exit_code == 0 1CDEFGHI

330 assert result.output == snapshot("""\ 1CDEFGHI

331disconnect resource 

332 

333💡 The managed environment variables DATABASE_URL, DATABASE_PASSWORD will be 

334 removed from the app. The Neon resource itself will not be deleted. 

335 

336 Disconnect production? 

337 ○ Yes ● No 

338 

339 Disconnect production? No 

340 

341 Disconnection cancelled.\ 

342""") 

343 

344 

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 ) 

353 

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

355 app, 

356 ["integrations", "resources", "disconnect", "--app-id", APP_ID], 

357 ) 

358 

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

360 assert result.output == snapshot("""\ 1#$%'()*

361disconnect resource 

362 

363No connected resources found.\ 

364""") 

365 

366 

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 ) 

375 

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 ) 

389 

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