Coverage for tests/test_cli_setup_ci.py: 100%

289 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-11-09 00:25 +0000

1import subprocess 1abcdefg

2from pathlib import Path, PureWindowsPath 1abcdefg

3from typing import cast 1abcdefg

4from unittest.mock import patch 1abcdefg

5 

6import pytest 1abcdefg

7import respx 1abcdefg

8from httpx import Response 1abcdefg

9from typer.testing import CliRunner 1abcdefg

10 

11from fastapi_cloud_cli.cli import cloud_app as app 1abcdefg

12from fastapi_cloud_cli.commands.setup_ci import ( 1abcdefg

13 GitHubSecretError, 

14 _check_gh_cli_installed, 

15 _check_git_installed, 

16 _format_workflow_path, 

17 _get_default_branch, 

18 _get_remote_origin, 

19 _resolve_existing_workflow_path, 

20 _set_github_secret, 

21) 

22from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit, get_rich_toolkit 1abcdefg

23from tests.conftest import ConfiguredApp 1abcdefg

24from tests.utils import Keys, changing_dir 1abcdefg

25 

26runner = CliRunner() 1abcdefg

27 

28GITHUB_ORIGIN = "git@github.com:owner/repo.git" 1abcdefg

29GITLAB_ORIGIN = "git@gitlab.com:owner/repo.git" 1abcdefg

30 

31 

32def _mock_token_api( 1abcdefg

33 respx_mock: respx.MockRouter, app_id: str, *, token_value: str = "test-token" 

34) -> None: 

35 """Set up token API mocks for tests that create tokens.""" 

36 respx_mock.post(f"/apps/{app_id}/tokens").mock( 2] Z h s ^ kb_ lbt o 0 I p u mb( j J nbIbobJbK v ) 1 w L pb* k M qbKbrbLbN x + 2 y O sb, l P tbMbubNbQ z - 3 A R ` 4 i B { vb| wbC q 5 S r D xb. m T ybObzbPbU E / 6 F V Ab: n W BbQbCbRbX G ; 7 H Y

37 return_value=Response( 

38 201, 

39 json={"value": token_value, "expired_at": "2027-02-18T00:00:00Z"}, 

40 ) 

41 ) 

42 

43 

44def test_shows_login_message_when_not_logged_in(logged_out_cli: None) -> None: 1abcdefg

45 result = runner.invoke(app, ["setup-ci"]) 25b6b7b8b9b!b#b

46 

47 assert result.exit_code == 1 25b6b7b8b9b!b#b

48 assert "No credentials found" in result.output 25b6b7b8b9b!b#b

49 

50 

51def test_shows_message_if_app_not_configured(logged_in_cli: None) -> None: 1abcdefg

52 result = runner.invoke(app, ["setup-ci"]) 2$b%b'b(b)b*b+b

53 

54 assert result.exit_code == 1 2$b%b'b(b)b*b+b

55 assert "App ID is required." in result.output 2$b%b'b(b)b*b+b

56 

57 

58def test_shows_error_when_secrets_only_and_workflow_only_conflict( 1abcdefg

59 logged_in_cli: None, 

60) -> None: 

61 result = runner.invoke(app, ["setup-ci", "--secrets-only", "--workflow-only"]) 2,b-b.b/b:b;b=b

62 

63 assert result.exit_code == 1 2,b-b.b/b:b;b=b

64 assert "--secrets-only and --workflow-only cannot be used together" in result.output 2,b-b.b/b:b;b=b

65 

66 

67def test_shows_error_when_git_not_installed( 1abcdefg

68 logged_in_cli: None, configured_app: ConfiguredApp 

69) -> None: 

70 with ( 2UbVb

71 changing_dir(configured_app.path), 

72 patch( 

73 "fastapi_cloud_cli.commands.setup_ci._check_git_installed", 

74 return_value=False, 

75 ), 

76 ): 

77 result = runner.invoke(app, ["setup-ci"]) 2Ub?b@b[bVb]b^b

78 

79 assert result.exit_code == 1 2Ub?b@b[bVb]b^b

80 assert "git is not installed" in result.output 2Ub?b@b[bVb]b^b

81 

82 

83def test_exits_with_error_when_no_remote_origin( 1abcdefg

84 logged_in_cli: None, configured_app: ConfiguredApp 

85) -> None: 

86 with ( 2WbXb

87 changing_dir(configured_app.path), 

88 patch( 

89 "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", 

90 side_effect=subprocess.CalledProcessError(1, "git"), 

91 ), 

92 ): 

93 result = runner.invoke(app, ["setup-ci"]) 2Wb_b`b{bXb|b}b

94 

95 assert result.exit_code == 1 2Wb_b`b{bXb|b}b

96 assert "Could not retrieve the git remote origin URL" in result.output 2Wb_b`b{bXb|b}b

97 

98 

99def test_shows_error_when_origin_is_not_github( 1abcdefg

100 logged_in_cli: None, configured_app: ConfiguredApp 

101) -> None: 

102 with ( 2YbZb

103 changing_dir(configured_app.path), 

104 patch( 

105 "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", 

106 return_value=GITLAB_ORIGIN, 

107 ), 

108 ): 

109 result = runner.invoke(app, ["setup-ci"]) 2Yb~bacbcZbccdc

110 

111 assert result.exit_code == 1 2Yb~bacbcZbccdc

112 assert "Remote origin is not a GitHub repository" in result.output 2Yb~bacbcZbccdc

113 

114 

115@pytest.mark.respx 1abcdefg

116def test_detects_github_origin_and_completes_successfully( 1abcdefg

117 logged_in_cli: None, 

118 configured_app: ConfiguredApp, 

119 respx_mock: respx.MockRouter, 

120) -> None: 

121 _mock_token_api(respx_mock, configured_app.app_id) 2^ nbqbtb{ ybBb

122 

123 with ( 1^{

124 changing_dir(configured_app.path), 

125 patch( 

126 "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", 

127 return_value=GITHUB_ORIGIN, 

128 ), 

129 patch( 

130 "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", 

131 return_value=True, 

132 ), 

133 patch( 

134 "fastapi_cloud_cli.commands.setup_ci._get_default_branch", 

135 return_value="main", 

136 ), 

137 patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), 

138 patch.object(FastAPIRichToolkit, "confirm", return_value=True), 

139 ): 

140 result = runner.invoke(app, ["setup-ci"]) 2^ nbqbtb{ ybBb

141 

142 assert result.exit_code == 0 2^ nbqbtb{ ybBb

143 assert "owner/repo" in result.output 2^ nbqbtb{ ybBb

144 assert "Done" in result.output 2^ nbqbtb{ ybBb

145 

146 

147@pytest.mark.respx 1abcdefg

148def test_app_id_option_works_without_linked_directory( 1abcdefg

149 logged_in_cli: None, 

150 tmp_path: Path, 

151 respx_mock: respx.MockRouter, 

152) -> None: 

153 app_id = "explicit-app" 2] mbpbsb` xbAb

154 _mock_token_api(respx_mock, app_id) 2] mbpbsb` xbAb

155 

156 with ( 1]`

157 changing_dir(tmp_path), 

158 patch( 

159 "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", 

160 return_value=GITHUB_ORIGIN, 

161 ), 

162 patch( 

163 "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", 

164 return_value=True, 

165 ), 

166 patch( 

167 "fastapi_cloud_cli.commands.setup_ci._get_default_branch", 

168 return_value="main", 

169 ), 

170 patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), 

171 patch.object(FastAPIRichToolkit, "confirm", return_value=True), 

172 ): 

173 result = runner.invoke(app, ["setup-ci", "--app-id", app_id]) 2] mbpbsb` xbAb

174 

175 assert result.exit_code == 0 2] mbpbsb` xbAb

176 assert "Done" in result.output 2] mbpbsb` xbAb

177 

178 

179@pytest.mark.respx 1abcdefg

180def test_detects_non_main_default_branch( 1abcdefg

181 logged_in_cli: None, 

182 configured_app: ConfiguredApp, 

183 respx_mock: respx.MockRouter, 

184) -> None: 

185 _mock_token_api(respx_mock, configured_app.app_id) 2kbIbKbMbvbObQb

186 

187 with ( 2kbvb

188 changing_dir(configured_app.path), 

189 patch( 

190 "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", 

191 return_value=GITHUB_ORIGIN, 

192 ), 

193 patch( 

194 "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", 

195 return_value=True, 

196 ), 

197 patch( 

198 "fastapi_cloud_cli.commands.setup_ci._get_default_branch", 

199 return_value="develop", 

200 ), 

201 patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), 

202 patch.object(FastAPIRichToolkit, "confirm", return_value=True), 

203 ): 

204 result = runner.invoke(app, ["setup-ci"]) 2kbIbKbMbvbObQb

205 

206 assert result.exit_code == 0 2kbIbKbMbvbObQb

207 assert "(branch: develop)" in result.output 2kbIbKbMbvbObQb

208 

209 

210def test_get_default_branch_falls_back_to_main() -> None: 1abcdefg

211 with patch( 2zcAcBcCcDcEcFc

212 "fastapi_cloud_cli.commands.setup_ci.subprocess.run", 

213 side_effect=subprocess.CalledProcessError(1, "git"), 

214 ): 

215 assert _get_default_branch() == "main" 2zcAcBcCcDcEcFc

216 

217 

218def test_get_default_branch_returns_branch_name() -> None: 1abcdefg

219 with patch( 2GcHcIcJcKcLcMc

220 "fastapi_cloud_cli.commands.setup_ci.subprocess.run", 

221 return_value=subprocess.CompletedProcess([], 0, stdout="develop\n"), 

222 ): 

223 assert _get_default_branch() == "develop" 2GcHcIcJcKcLcMc

224 

225 

226def test_check_git_installed_returns_true() -> None: 1abcdefg

227 with patch( 2NcOcPcQcRcScTc

228 "fastapi_cloud_cli.commands.setup_ci.shutil.which", return_value="/usr/bin/git" 

229 ): 

230 assert _check_git_installed() is True 2NcOcPcQcRcScTc

231 

232 

233def test_check_git_installed_returns_false_when_missing() -> None: 1abcdefg

234 with patch("fastapi_cloud_cli.commands.setup_ci.shutil.which", return_value=None): 2UcVcWcXcYcZc0c

235 assert _check_git_installed() is False 2UcVcWcXcYcZc0c

236 

237 

238def test_check_gh_cli_installed_returns_true() -> None: 1abcdefg

239 with patch( 21c2c3c4c5c6c7c

240 "fastapi_cloud_cli.commands.setup_ci.shutil.which", return_value="/usr/bin/gh" 

241 ): 

242 assert _check_gh_cli_installed() is True 21c2c3c4c5c6c7c

243 

244 

245def test_check_gh_cli_installed_returns_false_when_missing() -> None: 1abcdefg

246 with patch("fastapi_cloud_cli.commands.setup_ci.shutil.which", return_value=None): 28c9c!c#c$c%c'c

247 assert _check_gh_cli_installed() is False 28c9c!c#c$c%c'c

248 

249 

250def test_format_workflow_path_uses_forward_slashes() -> None: 1abcdefg

251 assert ( 2udvdwdxdydzdAd

252 _format_workflow_path(PureWindowsPath(".github/workflows/deploy.yml")) 

253 == ".github/workflows/deploy.yml" 

254 ) 

255 

256 

257def test_existing_workflow_prompt_uses_forward_slashes( 1abcdefg

258 monkeypatch: pytest.MonkeyPatch, 

259) -> None: 

260 labels: list[str] = [] 189!#$%'

261 workflow_path = cast(Path, PureWindowsPath(".github/workflows/deploy.yml")) 189!#$%'

262 

263 def confirm(label: str, **metadata: object) -> bool: 189!#$%'

264 labels.append(label) 189!#$%'

265 return True 189!#$%'

266 

267 with get_rich_toolkit(minimal=True) as toolkit: 189!#$%'

268 monkeypatch.setattr(toolkit, "confirm", confirm) 189!#$%'

269 

270 assert _resolve_existing_workflow_path(toolkit, workflow_path) == workflow_path 189!#$%'

271 

272 assert labels == [ 189!#$%'

273 "Workflow file [bold].github/workflows/deploy.yml[/bold] already exists. Overwrite?" 

274 ] 

275 

276 

277def test_get_remote_origin_returns_url() -> None: 1abcdefg

278 with patch( 2(c)c*c+c,c-c.c

279 "fastapi_cloud_cli.commands.setup_ci.subprocess.run", 

280 return_value=subprocess.CompletedProcess( 

281 [], 0, stdout="git@github.com:owner/repo.git\n" 

282 ), 

283 ): 

284 assert _get_remote_origin() == "git@github.com:owner/repo.git" 2(c)c*c+c,c-c.c

285 

286 

287def test_get_remote_origin_falls_back_to_git_when_gh_fails() -> None: 1abcdefg

288 with patch( 2/c:c;c=c?c@c[c

289 "fastapi_cloud_cli.commands.setup_ci.subprocess.run", 

290 side_effect=[ 

291 subprocess.CalledProcessError(1, "gh"), # gh fails 

292 subprocess.CompletedProcess( 

293 [], 0, stdout="git@github.com:owner/repo.git\n" 

294 ), 

295 ], 

296 ): 

297 assert _get_remote_origin() == "git@github.com:owner/repo.git" 2/c:c;c=c?c@c[c

298 

299 

300def test_set_github_secret_calls_gh_cli() -> None: 1abcdefg

301 with patch("fastapi_cloud_cli.commands.setup_ci.subprocess.run") as mock_run: 2ecfcgchcicjckc

302 _set_github_secret("MY_SECRET", "my-value") 2ecfcgchcicjckc

303 

304 mock_run.assert_called_once_with( 2ecfcgchcicjckc

305 ["gh", "secret", "set", "MY_SECRET", "--body", "my-value"], 

306 capture_output=True, 

307 check=True, 

308 ) 

309 

310 

311def test_set_github_secret_raises_custom_exception_on_command_error() -> None: 1abcdefg

312 with patch( 2lcmcncocpcqcrc

313 "fastapi_cloud_cli.commands.setup_ci.subprocess.run", 

314 side_effect=subprocess.CalledProcessError(1, "gh"), 

315 ): 

316 with pytest.raises(GitHubSecretError, match="Failed to set GitHub secret"): 2lcmcncocpcqcrc

317 _set_github_secret("MY_SECRET", "my-value") 2lcmcncocpcqcrc

318 

319 

320def test_set_github_secret_raises_custom_exception_when_gh_not_found() -> None: 1abcdefg

321 with patch( 2sctcucvcwcxcyc

322 "fastapi_cloud_cli.commands.setup_ci.subprocess.run", 

323 side_effect=FileNotFoundError("gh not found"), 

324 ): 

325 with pytest.raises(GitHubSecretError, match="Failed to set GitHub secret"): 2sctcucvcwcxcyc

326 _set_github_secret("MY_SECRET", "my-value") 2sctcucvcwcxcyc

327 

328 

329def test_dry_run_shows_planned_steps( 1abcdefg

330 logged_in_cli: None, configured_app: ConfiguredApp 

331) -> None: 

332 with ( 1=?

333 changing_dir(configured_app.path), 

334 patch( 

335 "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", 

336 return_value=GITHUB_ORIGIN, 

337 ), 

338 patch( 

339 "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", 

340 return_value=True, 

341 ), 

342 patch( 

343 "fastapi_cloud_cli.commands.setup_ci._get_default_branch", 

344 return_value="main", 

345 ), 

346 ): 

347 result = runner.invoke(app, ["setup-ci", "--dry-run"]) 2= } ~ ab? bbcb

348 

349 assert result.exit_code == 0 2= } ~ ab? bbcb

350 assert "dry run" in result.output.lower() 2= } ~ ab? bbcb

351 assert "Created deploy token" in result.output 2= } ~ ab? bbcb

352 assert "FASTAPI_CLOUD_TOKEN" in result.output 2= } ~ ab? bbcb

353 assert "FASTAPI_CLOUD_APP_ID" in result.output 2= } ~ ab? bbcb

354 assert "deploy.yml" in result.output 2= } ~ ab? bbcb

355 

356 

357def test_dry_run_secrets_only_skips_workflow( 1abcdefg

358 logged_in_cli: None, configured_app: ConfiguredApp 

359) -> None: 

360 with ( 2SbTb

361 changing_dir(configured_app.path), 

362 patch( 

363 "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", 

364 return_value=GITHUB_ORIGIN, 

365 ), 

366 patch( 

367 "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", 

368 return_value=True, 

369 ), 

370 patch( 

371 "fastapi_cloud_cli.commands.setup_ci._get_default_branch", 

372 return_value="main", 

373 ), 

374 ): 

375 result = runner.invoke(app, ["setup-ci", "--dry-run", "--secrets-only"]) 2Sb0b1b2bTb3b4b

376 

377 assert result.exit_code == 0 2Sb0b1b2bTb3b4b

378 assert "FASTAPI_CLOUD_TOKEN" in result.output 2Sb0b1b2bTb3b4b

379 assert "deploy.yml" not in result.output 2Sb0b1b2bTb3b4b

380 

381 

382@pytest.mark.respx 1abcdefg

383def test_secrets_only_skips_workflow_file( 1abcdefg

384 logged_in_cli: None, 

385 configured_app: ConfiguredApp, 

386 respx_mock: respx.MockRouter, 

387) -> None: 

388 _mock_token_api(respx_mock, configured_app.app_id) 10)+-5/;

389 

390 with ( 105

391 changing_dir(configured_app.path), 

392 patch( 

393 "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", 

394 return_value=GITHUB_ORIGIN, 

395 ), 

396 patch( 

397 "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", 

398 return_value=True, 

399 ), 

400 patch( 

401 "fastapi_cloud_cli.commands.setup_ci._get_default_branch", 

402 return_value="main", 

403 ), 

404 patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), 

405 patch.object(FastAPIRichToolkit, "confirm", return_value=True), 

406 ): 

407 result = runner.invoke(app, ["setup-ci", "--secrets-only"]) 10)+-5/;

408 

409 assert result.exit_code == 0 10)+-5/;

410 assert "FASTAPI_CLOUD_TOKEN" in result.output 10)+-5/;

411 assert "FASTAPI_CLOUD_APP_ID" in result.output 10)+-5/;

412 assert "Done" in result.output 10)+-5/;

413 assert not (configured_app.path / ".github" / "workflows" / "deploy.yml").exists() 10)+-5/;

414 

415 

416@pytest.mark.respx 1abcdefg

417def test_branch_flag_overrides_detected_branch( 1abcdefg

418 logged_in_cli: None, 

419 configured_app: ConfiguredApp, 

420 respx_mock: respx.MockRouter, 

421) -> None: 

422 _mock_token_api(respx_mock, configured_app.app_id) 1Z(*,4.:

423 

424 with ( 1Z4

425 changing_dir(configured_app.path), 

426 patch( 

427 "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", 

428 return_value=GITHUB_ORIGIN, 

429 ), 

430 patch( 

431 "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", 

432 return_value=True, 

433 ), 

434 patch( 

435 "fastapi_cloud_cli.commands.setup_ci._get_default_branch", 

436 return_value="main", 

437 ), 

438 patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), 

439 patch.object(FastAPIRichToolkit, "confirm", return_value=True), 

440 ): 

441 result = runner.invoke(app, ["setup-ci", "--branch", "production"]) 1Z(*,4.:

442 

443 assert result.exit_code == 0 1Z(*,4.:

444 assert "(branch: production)" in result.output 1Z(*,4.:

445 

446 workflow_file = configured_app.path / ".github" / "workflows" / "deploy.yml" 1Z(*,4.:

447 content = workflow_file.read_text() 1Z(*,4.:

448 assert "branches: [production]" in content 1Z(*,4.:

449 

450 

451@pytest.mark.respx 1abcdefg

452def test_creates_token_sets_secrets_and_writes_workflow( 1abcdefg

453 logged_in_cli: None, 

454 configured_app: ConfiguredApp, 

455 respx_mock: respx.MockRouter, 

456) -> None: 

457 app_id = configured_app.app_id 1hjklimn

458 _mock_token_api(respx_mock, app_id, token_value="test-token-value") 1hjklimn

459 

460 with ( 1hi

461 changing_dir(configured_app.path), 

462 patch( 

463 "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", 

464 return_value=GITHUB_ORIGIN, 

465 ), 

466 patch( 

467 "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", 

468 return_value=True, 

469 ), 

470 patch( 

471 "fastapi_cloud_cli.commands.setup_ci._get_default_branch", 

472 return_value="main", 

473 ), 

474 patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret") as mock_secret, 

475 patch.object(FastAPIRichToolkit, "confirm", return_value=True), 

476 ): 

477 result = runner.invoke(app, ["setup-ci"]) 1hjklimn

478 

479 assert result.exit_code == 0 1hjklimn

480 assert "Created deploy token" in result.output 1hjklimn

481 assert "FASTAPI_CLOUD_TOKEN" in result.output 1hjklimn

482 assert "FASTAPI_CLOUD_APP_ID" in result.output 1hjklimn

483 assert "deploy.yml" in result.output 1hjklimn

484 assert "Done" in result.output 1hjklimn

485 assert "2027-02-18" in result.output 1hjklimn

486 assert "test-token-value" not in result.output 1hjklimn

487 

488 mock_secret.assert_any_call("FASTAPI_CLOUD_TOKEN", "test-token-value") 1hjklimn

489 mock_secret.assert_any_call("FASTAPI_CLOUD_APP_ID", app_id) 1hjklimn

490 

491 workflow_file = configured_app.path / ".github" / "workflows" / "deploy.yml" 1hjklimn

492 assert workflow_file.exists() 1hjklimn

493 content = workflow_file.read_text() 1hjklimn

494 assert "Deploy to FastAPI Cloud" in content 1hjklimn

495 assert "branches: [main]" in content 1hjklimn

496 

497 

498def test_declining_token_creation_skips_token_and_secret_setup( 1abcdefg

499 logged_in_cli: None, 

500 configured_app: ConfiguredApp, 

501) -> None: 

502 with ( 1@[

503 changing_dir(configured_app.path), 

504 patch( 

505 "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", 

506 return_value=GITHUB_ORIGIN, 

507 ), 

508 patch( 

509 "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", 

510 return_value=True, 

511 ), 

512 patch( 

513 "fastapi_cloud_cli.commands.setup_ci._get_default_branch", 

514 return_value="main", 

515 ), 

516 patch.object(FastAPIRichToolkit, "confirm", return_value=False), 

517 patch("fastapi_cloud_cli.commands.setup_ci._create_token") as mock_create_token, 

518 patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret") as mock_secret, 

519 ): 

520 result = runner.invoke(app, ["setup-ci"]) 2@ dbebfb[ gbhb

521 

522 assert result.exit_code == 0 2@ dbebfb[ gbhb

523 assert "Skipped creating deploy token and GitHub secrets" in result.output 2@ dbebfb[ gbhb

524 mock_create_token.assert_not_called() 2@ dbebfb[ gbhb

525 mock_secret.assert_not_called() 2@ dbebfb[ gbhb

526 workflow_file = configured_app.path / ".github" / "workflows" / "deploy.yml" 2@ dbebfb[ gbhb

527 assert workflow_file.exists() 2@ dbebfb[ gbhb

528 

529 

530def test_declining_token_creation_for_secrets_only_finishes_without_setup( 1abcdefg

531 logged_in_cli: None, 

532 configured_app: ConfiguredApp, 

533) -> None: 

534 with ( 2ibjb

535 changing_dir(configured_app.path), 

536 patch( 

537 "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", 

538 return_value=GITHUB_ORIGIN, 

539 ), 

540 patch( 

541 "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", 

542 return_value=True, 

543 ), 

544 patch( 

545 "fastapi_cloud_cli.commands.setup_ci._get_default_branch", 

546 return_value="main", 

547 ), 

548 patch.object(FastAPIRichToolkit, "confirm", return_value=False), 

549 patch("fastapi_cloud_cli.commands.setup_ci._create_token") as mock_create_token, 

550 patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret") as mock_secret, 

551 ): 

552 result = runner.invoke(app, ["setup-ci", "--secrets-only"]) 2ibDbEbFbjbGbHb

553 

554 assert result.exit_code == 0 2ibDbEbFbjbGbHb

555 assert "Skipped creating deploy token and GitHub secrets" in result.output 2ibDbEbFbjbGbHb

556 assert "Done" in result.output 2ibDbEbFbjbGbHb

557 mock_create_token.assert_not_called() 2ibDbEbFbjbGbHb

558 mock_secret.assert_not_called() 2ibDbEbFbjbGbHb

559 

560 

561@pytest.mark.respx 1abcdefg

562def test_declining_github_secret_setup_keeps_created_token( 1abcdefg

563 logged_in_cli: None, 

564 configured_app: ConfiguredApp, 

565 respx_mock: respx.MockRouter, 

566) -> None: 

567 _mock_token_api(respx_mock, configured_app.app_id) 1sJMPBTW

568 

569 with ( 1sB

570 changing_dir(configured_app.path), 

571 patch( 

572 "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", 

573 return_value=GITHUB_ORIGIN, 

574 ), 

575 patch( 

576 "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", 

577 return_value=True, 

578 ), 

579 patch( 

580 "fastapi_cloud_cli.commands.setup_ci._get_default_branch", 

581 return_value="main", 

582 ), 

583 patch.object(FastAPIRichToolkit, "confirm", side_effect=[True, False]), 

584 patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret") as mock_secret, 

585 ): 

586 result = runner.invoke(app, ["setup-ci"]) 1sJMPBTW

587 

588 assert result.exit_code == 0 1sJMPBTW

589 assert "Created deploy token" in result.output 1sJMPBTW

590 assert "Skipped setting GitHub Actions secrets" in result.output 1sJMPBTW

591 assert "2027-02-18" in result.output 1sJMPBTW

592 mock_secret.assert_not_called() 1sJMPBTW

593 workflow_file = configured_app.path / ".github" / "workflows" / "deploy.yml" 1sJMPBTW

594 assert workflow_file.exists() 1sJMPBTW

595 

596 

597@pytest.mark.respx 1abcdefg

598def test_shows_manual_instructions_when_gh_not_installed( 1abcdefg

599 logged_in_cli: None, 

600 configured_app: ConfiguredApp, 

601 respx_mock: respx.MockRouter, 

602) -> None: 

603 _mock_token_api(respx_mock, configured_app.app_id) 1pwyArFH

604 

605 with ( 1pr

606 changing_dir(configured_app.path), 

607 patch( 

608 "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", 

609 return_value=GITHUB_ORIGIN, 

610 ), 

611 patch( 

612 "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", 

613 return_value=False, 

614 ), 

615 patch( 

616 "fastapi_cloud_cli.commands.setup_ci._get_default_branch", 

617 return_value="main", 

618 ), 

619 patch.object(FastAPIRichToolkit, "confirm", return_value=True), 

620 patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret") as mock_secret, 

621 ): 

622 result = runner.invoke(app, ["setup-ci"]) 1pwyArFH

623 

624 assert result.exit_code == 0 1pwyArFH

625 assert "gh CLI not found" in result.output 1pwyArFH

626 assert "https://github.com/owner/repo/settings/secrets/actions" in result.output 1pwyArFH

627 assert "FASTAPI_CLOUD_TOKEN = test-token" in result.output 1pwyArFH

628 assert f"FASTAPI_CLOUD_APP_ID = {configured_app.app_id}" in result.output 1pwyArFH

629 assert "Done" in result.output 1pwyArFH

630 assert "2027-02-18" in result.output 1pwyArFH

631 mock_secret.assert_not_called() 1pwyArFH

632 

633 

634@pytest.mark.respx 1abcdefg

635def test_handles_gh_command_errors_gracefully( 1abcdefg

636 logged_in_cli: None, 

637 configured_app: ConfiguredApp, 

638 respx_mock: respx.MockRouter, 

639) -> None: 

640 _mock_token_api(respx_mock, configured_app.app_id) 2lbJbLbNbwbPbRb

641 

642 with ( 2lbwb

643 changing_dir(configured_app.path), 

644 patch( 

645 "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", 

646 return_value=GITHUB_ORIGIN, 

647 ), 

648 patch( 

649 "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", 

650 return_value=True, 

651 ), 

652 patch( 

653 "fastapi_cloud_cli.commands.setup_ci._get_default_branch", 

654 return_value="main", 

655 ), 

656 patch( 

657 "fastapi_cloud_cli.commands.setup_ci._set_github_secret", 

658 side_effect=GitHubSecretError( 

659 "Failed to set GitHub secret 'FASTAPI_CLOUD_TOKEN'" 

660 ), 

661 ), 

662 patch.object(FastAPIRichToolkit, "confirm", return_value=True), 

663 ): 

664 result = runner.invoke(app, ["setup-ci"]) 2lbJbLbNbwbPbRb

665 

666 assert result.exit_code == 1 2lbJbLbNbwbPbRb

667 assert "Failed to set GitHub secrets" in result.output 2lbJbLbNbwbPbRb

668 

669 

670@pytest.mark.respx 1abcdefg

671def test_file_flag_uses_custom_filename( 1abcdefg

672 logged_in_cli: None, 

673 configured_app: ConfiguredApp, 

674 respx_mock: respx.MockRouter, 

675) -> None: 

676 _mock_token_api(respx_mock, configured_app.app_id) 2_ obrbub| zbCb

677 

678 with ( 1_|

679 changing_dir(configured_app.path), 

680 patch( 

681 "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", 

682 return_value=GITHUB_ORIGIN, 

683 ), 

684 patch( 

685 "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", 

686 return_value=True, 

687 ), 

688 patch( 

689 "fastapi_cloud_cli.commands.setup_ci._get_default_branch", 

690 return_value="main", 

691 ), 

692 patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), 

693 patch.object(FastAPIRichToolkit, "confirm", return_value=True), 

694 ): 

695 result = runner.invoke(app, ["setup-ci", "--file", "ci.yml"]) 2_ obrbub| zbCb

696 

697 assert result.exit_code == 0 2_ obrbub| zbCb

698 assert "ci.yml" in result.output 2_ obrbub| zbCb

699 assert (configured_app.path / ".github" / "workflows" / "ci.yml").exists() 2_ obrbub| zbCb

700 

701 

702@pytest.mark.respx 1abcdefg

703def test_overwrites_existing_workflow_when_confirmed( 1abcdefg

704 logged_in_cli: None, 

705 configured_app: ConfiguredApp, 

706 respx_mock: respx.MockRouter, 

707) -> None: 

708 _mock_token_api(respx_mock, configured_app.app_id) 1tKNQCUX

709 

710 workflow_dir = configured_app.path / ".github" / "workflows" 1tKNQCUX

711 workflow_dir.mkdir(parents=True) 1tKNQCUX

712 (workflow_dir / "deploy.yml").write_text("old content") 1tKNQCUX

713 

714 with ( 1tC

715 changing_dir(configured_app.path), 

716 patch( 

717 "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", 

718 return_value=GITHUB_ORIGIN, 

719 ), 

720 patch( 

721 "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", 

722 return_value=True, 

723 ), 

724 patch( 

725 "fastapi_cloud_cli.commands.setup_ci._get_default_branch", 

726 return_value="main", 

727 ), 

728 patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), 

729 patch("rich_toolkit.container.getchar") as mock_getchar, 

730 ): 

731 mock_getchar.side_effect = [ 1tKNQCUX

732 Keys.ENTER, 

733 Keys.ENTER, 

734 Keys.RIGHT_ARROW, 

735 Keys.ENTER, 

736 ] 

737 result = runner.invoke(app, ["setup-ci"]) 1tKNQCUX

738 

739 assert result.exit_code == 0 1tKNQCUX

740 assert "already exists" in result.output 1tKNQCUX

741 assert "Deploy to FastAPI Cloud" in (workflow_dir / "deploy.yml").read_text() 1tKNQCUX

742 

743 

744@pytest.mark.respx 1abcdefg

745def test_skips_writing_workflow_when_declined( 1abcdefg

746 logged_in_cli: None, 

747 configured_app: ConfiguredApp, 

748 respx_mock: respx.MockRouter, 

749) -> None: 

750 _mock_token_api(respx_mock, configured_app.app_id) 1uLORDVY

751 

752 workflow_dir = configured_app.path / ".github" / "workflows" 1uLORDVY

753 workflow_dir.mkdir(parents=True) 1uLORDVY

754 (workflow_dir / "deploy.yml").write_text("old content") 1uLORDVY

755 

756 with ( 1uD

757 changing_dir(configured_app.path), 

758 patch( 

759 "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", 

760 return_value=GITHUB_ORIGIN, 

761 ), 

762 patch( 

763 "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", 

764 return_value=True, 

765 ), 

766 patch( 

767 "fastapi_cloud_cli.commands.setup_ci._get_default_branch", 

768 return_value="main", 

769 ), 

770 patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), 

771 patch("rich_toolkit.container.getchar") as mock_getchar, 

772 ): 

773 mock_getchar.side_effect = [ 1uLORDVY

774 Keys.ENTER, 

775 Keys.ENTER, 

776 Keys.ENTER, 

777 Keys.ENTER, 

778 ] 

779 result = runner.invoke(app, ["setup-ci"]) 1uLORDVY

780 

781 assert result.exit_code == 0 1uLORDVY

782 assert "Skipped writing workflow file" in result.output 1uLORDVY

783 assert (workflow_dir / "deploy.yml").read_text() == "old content" 1uLORDVY

784 

785 

786@pytest.mark.respx 1abcdefg

787def test_renames_workflow_when_declined_and_new_name_given( 1abcdefg

788 logged_in_cli: None, 

789 configured_app: ConfiguredApp, 

790 respx_mock: respx.MockRouter, 

791) -> None: 

792 _mock_token_api(respx_mock, configured_app.app_id) 1ovxzqEG

793 

794 workflow_dir = configured_app.path / ".github" / "workflows" 1ovxzqEG

795 workflow_dir.mkdir(parents=True) 1ovxzqEG

796 (workflow_dir / "deploy.yml").write_text("old content") 1ovxzqEG

797 

798 with ( 1oq

799 changing_dir(configured_app.path), 

800 patch( 

801 "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", 

802 return_value=GITHUB_ORIGIN, 

803 ), 

804 patch( 

805 "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", 

806 return_value=True, 

807 ), 

808 patch( 

809 "fastapi_cloud_cli.commands.setup_ci._get_default_branch", 

810 return_value="main", 

811 ), 

812 patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), 

813 patch("rich_toolkit.container.getchar") as mock_getchar, 

814 ): 

815 mock_getchar.side_effect = [ 1ovxzqEG

816 Keys.ENTER, 

817 Keys.ENTER, 

818 Keys.ENTER, 

819 *"ci-deploy.yml", 

820 Keys.ENTER, 

821 ] 

822 result = runner.invoke(app, ["setup-ci"]) 1ovxzqEG

823 

824 assert result.exit_code == 0 1ovxzqEG

825 assert "ci-deploy.yml" in result.output 1ovxzqEG

826 assert (workflow_dir / "deploy.yml").read_text() == "old content" 1ovxzqEG

827 assert (workflow_dir / "ci-deploy.yml").exists() 1ovxzqEG

828 

829 

830def test_get_github_host_extracts_from_ssh_url() -> None: 1abcdefg

831 from fastapi_cloud_cli.commands.setup_ci import _get_github_host 2]c^c_c`c{c|c}c

832 

833 assert _get_github_host("git@github.com:owner/repo.git") == "github.com" 2]c^c_c`c{c|c}c

834 

835 

836def test_get_github_host_extracts_from_https_url() -> None: 1abcdefg

837 from fastapi_cloud_cli.commands.setup_ci import _get_github_host 2~cadbdcdddedfd

838 

839 assert _get_github_host("https://github.com/owner/repo.git") == "github.com" 2~cadbdcdddedfd

840 

841 

842def test_get_github_host_extracts_from_enterprise_ssh_url() -> None: 1abcdefg

843 from fastapi_cloud_cli.commands.setup_ci import _get_github_host 2gdhdidjdkdldmd

844 

845 assert ( 2gdhdidjdkdldmd

846 _get_github_host("git@github.enterprise.com:owner/repo.git") 

847 == "github.enterprise.com" 

848 ) 

849 

850 

851def test_get_github_host_extracts_from_enterprise_https_url() -> None: 1abcdefg

852 from fastapi_cloud_cli.commands.setup_ci import _get_github_host 2ndodpdqdrdsdtd

853 

854 assert ( 2ndodpdqdrdsdtd

855 _get_github_host("https://github.enterprise.com/owner/repo.git") 

856 == "github.enterprise.com" 

857 ) 

858 

859 

860@pytest.mark.respx 1abcdefg

861def test_shows_enterprise_manual_secrets_url_when_gh_not_installed( 1abcdefg

862 logged_in_cli: None, 

863 configured_app: ConfiguredApp, 

864 respx_mock: respx.MockRouter, 

865) -> None: 

866 _mock_token_api(respx_mock, configured_app.app_id) 1I123S67

867 enterprise_origin = "git@github.enterprise.com:owner/repo.git" 1I123S67

868 

869 with ( 1IS

870 changing_dir(configured_app.path), 

871 patch( 

872 "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", 

873 return_value=enterprise_origin, 

874 ), 

875 patch( 

876 "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", 

877 return_value=False, 

878 ), 

879 patch( 

880 "fastapi_cloud_cli.commands.setup_ci._get_default_branch", 

881 return_value="main", 

882 ), 

883 patch.object(FastAPIRichToolkit, "confirm", return_value=True), 

884 patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret") as mock_secret, 

885 ): 

886 result = runner.invoke(app, ["setup-ci"]) 1I123S67

887 

888 assert result.exit_code == 0 1I123S67

889 assert ( 1I123S67

890 "https://github.enterprise.com/owner/repo/settings/secrets/actions" 

891 in result.output 

892 ) 

893 assert "FASTAPI_CLOUD_TOKEN = test-token" in result.output 1I123S67

894 assert "github.com/owner/repo" not in result.output 1I123S67

895 mock_secret.assert_not_called() 1I123S67