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

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 typer.testing import CliRunner 1abcdefg

9 

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

14 

15runner = CliRunner() 1abcdefg

16 

17 

18def test_link_json_requires_app_id(logged_in_cli: None) -> None: 1abcdefg

19 result = runner.invoke(app, ["link", "--json"]) 2` { | } ~ abbb

20 

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

30 

31 

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

34 

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

44 

45 

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

64 

65 result = runner.invoke( 1EFGHIJK

66 app, 

67 [ 

68 "link", 

69 app_id, 

70 "--path", 

71 str(path_to_link), 

72 "--json", 

73 ], 

74 ) 

75 

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

88 

89 

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

108 

109 result = runner.invoke( 1lmnopqr

110 app, 

111 [ 

112 "link", 

113 app_id, 

114 "--path", 

115 str(path_to_link), 

116 ], 

117 ) 

118 

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) 

127 

128 

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

147 

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 ) 

159 

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

172 

173 

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 ) 

187 

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

197 

198 

199def test_shows_a_message_if_not_logged_in(logged_out_cli: None) -> None: 1abcdefg

200 result = runner.invoke(app, ["link"]) 2xbybzbAbBbCbDb

201 

202 assert result.exit_code == 1 2xbybzbAbBbCbDb

203 assert "You need to be logged in to link an app." in result.output 2xbybzbAbBbCbDb

204 

205 

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

211 

212 assert result.exit_code == 1 2qbrbsbtbubvbwb

213 assert "This directory is already linked to an app." in result.output 2qbrbsbtbubvbwb

214 

215 

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+,-./:;

221 

222 with changing_dir(tmp_path): 1+,-./:;

223 result = runner.invoke(app, ["link"]) 1+,-./:;

224 

225 assert result.exit_code == 1 1+,-./:;

226 assert "No teams found" in result.output 1+,-./:;

227 

228 

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#$

234 

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 ) 

243 

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#$

250 

251 assert result.exit_code == 1 1489!5#$

252 assert "No apps found in this team." in result.output 1489!5#$

253 

254 

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

260 

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 ) 

269 

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

276 

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

280 

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

286 

287 

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=?@[]^_

293 

294 with changing_dir(tmp_path): 1=?@[]^_

295 result = runner.invoke(app, ["link"]) 1=?@[]^_

296 

297 assert result.exit_code == 1 1=?@[]^_

298 assert "Error fetching teams" in result.output 1=?@[]^_

299 

300 

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)*

306 

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 ) 

315 

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)*

322 

323 assert result.exit_code == 1 16%'(7)*

324 assert "Error fetching apps" in result.output 16%'(7)*

325 

326 

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

332 

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 ) 

349 

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

356 

357 assert result.exit_code == 0 1CSTUDVW

358 assert "Successfully linked to app" in result.output 1CSTUDVW

359 assert "alpha" in result.output 1CSTUDVW

360 

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

364 

365 

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

371 

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 ) 

386 

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

393 

394 assert result.exit_code == 0 1XZ01Y23

395 assert "Successfully linked to app" in result.output 1XZ01Y23

396 

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

400 

401 

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

407 

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 ) 

430 

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

437 

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

441 

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