Coverage for tests/test_version_check.py: 100%

152 statements  

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

1import json 1abcdefg

2from datetime import datetime, timedelta, timezone 1abcdefg

3from pathlib import Path 1abcdefg

4from types import SimpleNamespace 1abcdefg

5from typing import Any 1abcdefg

6 

7import click 1abcdefg

8import httpx 1abcdefg

9import pytest 1abcdefg

10import respx 1abcdefg

11import time_machine 1abcdefg

12from click.testing import CliRunner 1abcdefg

13 

14from fastapi_cloud_cli.utils.cli import get_rich_toolkit 1abcdefg

15from fastapi_cloud_cli.utils.config import get_version_check_cache_path 1abcdefg

16from fastapi_cloud_cli.utils.version_check import ( 1abcdefg

17 PYPI_JSON_URL, 

18 BackgroundVersionCheck, 

19 VersionUpdate, 

20 check_for_update, 

21 format_update_message, 

22 get_upgrade_command, 

23 is_newer_version, 

24 read_cached_latest_version, 

25 write_latest_version_cache, 

26) 

27 

28 

29@pytest.fixture 1abcdefg

30def current_version(monkeypatch: pytest.MonkeyPatch) -> None: 1abcdefg

31 monkeypatch.setattr("fastapi_cloud_cli.utils.version_check.__version__", "0.17.1") 1abcdefg

32 

33 

34def test_is_newer_version() -> None: 1abcdefg

35 assert is_newer_version("0.18.0", "0.17.1") 1JKLMNOP

36 assert not is_newer_version("0.17.1", "0.17.1") 1JKLMNOP

37 assert not is_newer_version("0.17.0", "0.17.1") 1JKLMNOP

38 assert not is_newer_version("0.18.0rc1", "0.17.1") 1JKLMNOP

39 assert not is_newer_version("0.17.1.post1", "0.17.1") 1JKLMNOP

40 assert not is_newer_version("not-a-version", "0.17.1") 1JKLMNOP

41 

42 

43@respx.mock 1abcdefg

44def test_check_for_update_returns_update_when_pypi_has_newer_version( 1abcdefg

45 current_version: None, 

46) -> None: 

47 route = respx.get(PYPI_JSON_URL).mock( 1=?@[]^_

48 return_value=httpx.Response(200, json={"info": {"version": "0.18.0"}}) 

49 ) 

50 

51 update = check_for_update() 1=?@[]^_

52 

53 assert route.called 1=?@[]^_

54 assert update == VersionUpdate(current="0.17.1", latest="0.18.0") 1=?@[]^_

55 

56 

57@respx.mock 1abcdefg

58@time_machine.travel( 1abcdefg

59 datetime(2026, 5, 20, 12, 5, tzinfo=timezone.utc), 

60 tick=False, 

61) 

62def test_check_for_update_uses_fresh_cache_without_network( 1abcdefg

63 current_version: None, 

64) -> None: 

65 cache_path = get_version_check_cache_path() 2` { | } ~ abbb

66 cache_path.write_text( 2` { | } ~ abbb

67 json.dumps( 

68 { 

69 "latest_version": "0.18.0", 

70 "checked_at": "2026-05-20T12:00:00+00:00", 

71 } 

72 ), 

73 encoding="utf-8", 

74 ) 

75 

76 update = check_for_update() 2` { | } ~ abbb

77 

78 assert update == VersionUpdate(current="0.17.1", latest="0.18.0") 2` { | } ~ abbb

79 

80 

81@respx.mock 1abcdefg

82@time_machine.travel( 1abcdefg

83 datetime(2026, 5, 20, 12, 5, tzinfo=timezone.utc), 

84 tick=False, 

85) 

86def test_check_for_update_refreshes_stale_cache( 1abcdefg

87 current_version: None, 

88) -> None: 

89 cache_path = get_version_check_cache_path() 1QRSTUVW

90 cache_path.write_text( 1QRSTUVW

91 json.dumps( 

92 { 

93 "latest_version": "0.18.0", 

94 "checked_at": "2026-05-18T12:00:00+00:00", 

95 } 

96 ), 

97 encoding="utf-8", 

98 ) 

99 

100 respx.get(PYPI_JSON_URL).mock( 1QRSTUVW

101 return_value=httpx.Response(200, json={"info": {"version": "0.19.0"}}) 

102 ) 

103 

104 update = check_for_update() 1QRSTUVW

105 

106 assert update == VersionUpdate(current="0.17.1", latest="0.19.0") 1QRSTUVW

107 assert json.loads(cache_path.read_text(encoding="utf-8")) == { 1QRSTUVW

108 "latest_version": "0.19.0", 

109 "checked_at": "2026-05-20T12:05:00+00:00", 

110 } 

111 

112 

113@respx.mock 1abcdefg

114def test_check_for_update_returns_none_when_current_is_latest( 1abcdefg

115 current_version: None, 

116) -> None: 

117 respx.get(PYPI_JSON_URL).mock( 2EbFbGbHbIbJbKb

118 return_value=httpx.Response(200, json={"info": {"version": "0.17.1"}}) 

119 ) 

120 

121 update = check_for_update() 2EbFbGbHbIbJbKb

122 

123 assert update is None 2EbFbGbHbIbJbKb

124 

125 

126@respx.mock 1abcdefg

127def test_check_for_update_ignores_network_errors(current_version: None) -> None: 1abcdefg

128 respx.get(PYPI_JSON_URL).mock(side_effect=httpx.ConnectError("offline")) 2LbMbNbObPbQbRb

129 

130 update = check_for_update() 2LbMbNbObPbQbRb

131 

132 assert update is None 2LbMbNbObPbQbRb

133 

134 

135@respx.mock 1abcdefg

136def test_check_for_update_ignores_invalid_payload(current_version: None) -> None: 1abcdefg

137 respx.get(PYPI_JSON_URL).mock( 2SbTbUbVbWbXbYb

138 return_value=httpx.Response(200, json={"info": {"version": None}}) 

139 ) 

140 

141 update = check_for_update() 2SbTbUbVbWbXbYb

142 

143 assert update is None 2SbTbUbVbWbXbYb

144 

145 

146@time_machine.travel( 1abcdefg

147 datetime(2026, 5, 20, 12, 0, tzinfo=timezone.utc), 

148 tick=False, 

149) 

150def test_read_cached_latest_version_ignores_stale_cache( 1abcdefg

151 tmp_path: Path, 

152) -> None: 

153 cache_path = tmp_path / "version-check.json" 2cbdbebfbgbhbib

154 cache_path.write_text( 2cbdbebfbgbhbib

155 json.dumps( 

156 { 

157 "latest_version": "0.18.0", 

158 "checked_at": "2026-05-18T12:00:00+00:00", 

159 } 

160 ), 

161 encoding="utf-8", 

162 ) 

163 

164 latest_version = read_cached_latest_version( 2cbdbebfbgbhbib

165 cache_path, 

166 ttl=timedelta(hours=24), 

167 ) 

168 

169 assert latest_version is None 2cbdbebfbgbhbib

170 

171 

172def test_read_cached_latest_version_ignores_invalid_cache(tmp_path: Path) -> None: 1abcdefg

173 cache_path = tmp_path / "version-check.json" 2jbkblbmbnbobpb

174 cache_path.write_text("not-json", encoding="utf-8") 2jbkblbmbnbobpb

175 

176 latest_version = read_cached_latest_version( 2jbkblbmbnbobpb

177 cache_path, 

178 ) 

179 

180 assert latest_version is None 2jbkblbmbnbobpb

181 

182 

183def test_read_cached_latest_version_ignores_non_simple_version( 1abcdefg

184 tmp_path: Path, 

185) -> None: 

186 cache_path = tmp_path / "version-check.json" 2qbrbsbtbubvbwb

187 cache_path.write_text( 2qbrbsbtbubvbwb

188 json.dumps( 

189 { 

190 "latest_version": "0.18.0rc1", 

191 "checked_at": "2026-05-20T12:00:00+00:00", 

192 } 

193 ), 

194 encoding="utf-8", 

195 ) 

196 

197 latest_version = read_cached_latest_version( 2qbrbsbtbubvbwb

198 cache_path, 

199 ) 

200 

201 assert latest_version is None 2qbrbsbtbubvbwb

202 

203 

204def test_write_latest_version_cache(tmp_path: Path) -> None: 1abcdefg

205 cache_path = tmp_path / "version-check.json" 2Zb0b1b2b3b4b5b

206 

207 write_latest_version_cache( 2Zb0b1b2b3b4b5b

208 cache_path, 

209 latest_version="0.18.0", 

210 now=datetime(2026, 5, 20, 12, 0, tzinfo=timezone.utc), 

211 ) 

212 

213 assert json.loads(cache_path.read_text(encoding="utf-8")) == { 2Zb0b1b2b3b4b5b

214 "latest_version": "0.18.0", 

215 "checked_at": "2026-05-20T12:00:00+00:00", 

216 } 

217 

218 

219def test_write_latest_version_cache_ignores_write_errors(tmp_path: Path) -> None: 1abcdefg

220 blocking_path = tmp_path / "not-a-directory" 2xbybzbAbBbCbDb

221 blocking_path.write_text("", encoding="utf-8") 2xbybzbAbBbCbDb

222 

223 write_latest_version_cache( 2xbybzbAbBbCbDb

224 blocking_path / "version-check.json", 

225 latest_version="0.18.0", 

226 now=datetime(2026, 5, 20, 12, 0, tzinfo=timezone.utc), 

227 ) 

228 

229 assert blocking_path.is_file() 2xbybzbAbBbCbDb

230 

231 

232def test_get_upgrade_command_uses_detected_installer( 1abcdefg

233 monkeypatch: pytest.MonkeyPatch, 

234) -> None: 

235 def fake_detect_installer(package_name: str) -> Any: 1456789!

236 assert package_name == "fastapi-cloud-cli" 1456789!

237 return SimpleNamespace(upgrade_cmd="uv tool upgrade fastapi-cloud-cli") 1456789!

238 

239 monkeypatch.setattr( 1456789!

240 "fastapi_cloud_cli.utils.version_check.detect_installer", 

241 fake_detect_installer, 

242 ) 

243 

244 assert get_upgrade_command() == "uv tool upgrade fastapi-cloud-cli" 1456789!

245 

246 

247def test_get_upgrade_command_falls_back_when_installer_is_unknown( 1abcdefg

248 monkeypatch: pytest.MonkeyPatch, 

249) -> None: 

250 monkeypatch.setattr( 26b7b8b9b!b#b$b

251 "fastapi_cloud_cli.utils.version_check.detect_installer", 

252 lambda package_name: None, 

253 ) 

254 

255 assert get_upgrade_command() == "pip install --upgrade fastapi-cloud-cli" 26b7b8b9b!b#b$b

256 

257 

258def test_get_upgrade_command_propagates_detector_errors( 1abcdefg

259 monkeypatch: pytest.MonkeyPatch, 

260) -> None: 

261 def broken_detector(package_name: str) -> None: 1#$%'()*

262 raise RuntimeError(f"Could not inspect {package_name}") 1#$%'()*

263 

264 monkeypatch.setattr( 1#$%'()*

265 "fastapi_cloud_cli.utils.version_check.detect_installer", 

266 broken_detector, 

267 ) 

268 

269 with pytest.raises(RuntimeError, match="Could not inspect fastapi-cloud-cli"): 1#$%'()*

270 get_upgrade_command() 1#$%'()*

271 

272 

273def test_format_update_message(monkeypatch: pytest.MonkeyPatch) -> None: 1abcdefg

274 monkeypatch.setattr( 1XYZ0123

275 "fastapi_cloud_cli.utils.version_check.get_upgrade_command", 

276 lambda: "uv tool upgrade fastapi-cloud-cli", 

277 ) 

278 

279 message = format_update_message(VersionUpdate(current="0.17.1", latest="0.18.0")) 1XYZ0123

280 

281 assert not message.startswith("\n") 1XYZ0123

282 assert "→ [bold]0.18.0[/]" in message 1XYZ0123

283 assert '\n\nRun "[blue]uv tool upgrade fastapi-cloud-cli[/]" to upgrade.' in message 1XYZ0123

284 assert "https://pypi.org/project/fastapi-cloud-cli/" not in message 1XYZ0123

285 

286 

287def _seed_fresh_update_cache(latest_version: str = "999.0.0") -> None: 1abcdefg

288 write_latest_version_cache( 1hoipjqkrlsmtnu

289 get_version_check_cache_path(), 

290 latest_version=latest_version, 

291 now=datetime.now(timezone.utc), 

292 ) 

293 

294 

295def test_get_rich_toolkit_prints_update_after_toolkit_output() -> None: 1abcdefg

296 @click.command() 1hijklmn

297 def command_with_toolkit() -> None: 1hijklmn

298 with get_rich_toolkit() as toolkit: 1hijklmn

299 toolkit.print("command output") 1hijklmn

300 

301 _seed_fresh_update_cache() 1hijklmn

302 result = CliRunner().invoke( 1hijklmn

303 command_with_toolkit, 

304 [], 

305 env={"FASTAPI_CLOUD_DISABLE_VERSION_CHECK": ""}, 

306 ) 

307 

308 assert result.exit_code == 0, result.output 1hijklmn

309 assert "command output" in result.output 1hijklmn

310 assert result.output.count("A newer FastAPI Cloud CLI version is available") == 1 1hijklmn

311 assert result.output.rfind("command output") < result.output.rfind("A newer") 1hijklmn

312 

313 

314def test_get_rich_toolkit_skips_update_when_disabled( 1abcdefg

315 monkeypatch: pytest.MonkeyPatch, 

316 capsys: pytest.CaptureFixture[str], 

317) -> None: 

318 monkeypatch.setenv("FASTAPI_CLOUD_DISABLE_VERSION_CHECK", "1") 1opqrstu

319 _seed_fresh_update_cache() 1opqrstu

320 

321 with get_rich_toolkit() as toolkit: 1opqrstu

322 toolkit.print("command output") 1opqrstu

323 

324 output = capsys.readouterr().out 1opqrstu

325 assert "command output" in output 1opqrstu

326 assert "A newer FastAPI Cloud CLI version is available" not in output 1opqrstu

327 

328 

329def test_background_check_stores_result_and_returns_message( 1abcdefg

330 monkeypatch: pytest.MonkeyPatch, 

331) -> None: 

332 monkeypatch.setattr( 1vwxyzAB

333 "fastapi_cloud_cli.utils.version_check.check_for_update", 

334 lambda: VersionUpdate(current="0.17.1", latest="0.18.0"), 

335 ) 

336 check = BackgroundVersionCheck() 1vwxyzAB

337 

338 check.start() 1vwxyzAB

339 message = check.get_update_message() 1vwxyzAB

340 

341 assert message is not None 1vwxyzAB

342 assert "A newer FastAPI Cloud CLI version is available" in message 1vwxyzAB

343 assert "→ [bold]0.18.0[/]" in message 1vwxyzAB

344 assert check.get_update_message() is None 1vwxyzAB

345 

346 

347def test_background_check_returns_no_message_without_update( 1abcdefg

348 monkeypatch: pytest.MonkeyPatch, 

349) -> None: 

350 monkeypatch.setattr( 1+,-./:;

351 "fastapi_cloud_cli.utils.version_check.check_for_update", 

352 lambda: None, 

353 ) 

354 check = BackgroundVersionCheck() 1+,-./:;

355 

356 check.start() 1+,-./:;

357 message = check.get_update_message() 1+,-./:;

358 

359 assert message is None 1+,-./:;

360 

361 

362def test_background_check_hides_unexpected_errors( 1abcdefg

363 monkeypatch: pytest.MonkeyPatch, 

364 recwarn: pytest.WarningsRecorder, 

365) -> None: 

366 def broken_check() -> None: 1CDEFGHI

367 raise RuntimeError("version check failed unexpectedly") 1CDEFGHI

368 

369 monkeypatch.setattr( 1CDEFGHI

370 "fastapi_cloud_cli.utils.version_check.check_for_update", 

371 broken_check, 

372 ) 

373 check = BackgroundVersionCheck() 1CDEFGHI

374 

375 check.start() 1CDEFGHI

376 

377 assert check.get_update_message() is None 1CDEFGHI

378 assert not any( 1CDEFGHI

379 warning.category is pytest.PytestUnhandledThreadExceptionWarning 

380 for warning in recwarn 

381 )