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
« 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
7import click 1abcdefg
8import httpx 1abcdefg
9import pytest 1abcdefg
10import respx 1abcdefg
11import time_machine 1abcdefg
12from click.testing import CliRunner 1abcdefg
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)
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
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
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 )
51 update = check_for_update() 1=?@[]^_
53 assert route.called 1=?@[]^_
54 assert update == VersionUpdate(current="0.17.1", latest="0.18.0") 1=?@[]^_
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 )
76 update = check_for_update() 2` { | } ~ abbb
78 assert update == VersionUpdate(current="0.17.1", latest="0.18.0") 2` { | } ~ abbb
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 )
100 respx.get(PYPI_JSON_URL).mock( 1QRSTUVW
101 return_value=httpx.Response(200, json={"info": {"version": "0.19.0"}})
102 )
104 update = check_for_update() 1QRSTUVW
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 }
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 )
121 update = check_for_update() 2EbFbGbHbIbJbKb
123 assert update is None 2EbFbGbHbIbJbKb
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
130 update = check_for_update() 2LbMbNbObPbQbRb
132 assert update is None 2LbMbNbObPbQbRb
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 )
141 update = check_for_update() 2SbTbUbVbWbXbYb
143 assert update is None 2SbTbUbVbWbXbYb
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 )
164 latest_version = read_cached_latest_version( 2cbdbebfbgbhbib
165 cache_path,
166 ttl=timedelta(hours=24),
167 )
169 assert latest_version is None 2cbdbebfbgbhbib
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
176 latest_version = read_cached_latest_version( 2jbkblbmbnbobpb
177 cache_path,
178 )
180 assert latest_version is None 2jbkblbmbnbobpb
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 )
197 latest_version = read_cached_latest_version( 2qbrbsbtbubvbwb
198 cache_path,
199 )
201 assert latest_version is None 2qbrbsbtbubvbwb
204def test_write_latest_version_cache(tmp_path: Path) -> None: 1abcdefg
205 cache_path = tmp_path / "version-check.json" 2Zb0b1b2b3b4b5b
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 )
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 }
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
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 )
229 assert blocking_path.is_file() 2xbybzbAbBbCbDb
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!
239 monkeypatch.setattr( 1456789!
240 "fastapi_cloud_cli.utils.version_check.detect_installer",
241 fake_detect_installer,
242 )
244 assert get_upgrade_command() == "uv tool upgrade fastapi-cloud-cli" 1456789!
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 )
255 assert get_upgrade_command() == "pip install --upgrade fastapi-cloud-cli" 26b7b8b9b!b#b$b
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#$%'()*
264 monkeypatch.setattr( 1#$%'()*
265 "fastapi_cloud_cli.utils.version_check.detect_installer",
266 broken_detector,
267 )
269 with pytest.raises(RuntimeError, match="Could not inspect fastapi-cloud-cli"): 1#$%'()*
270 get_upgrade_command() 1#$%'()*
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 )
279 message = format_update_message(VersionUpdate(current="0.17.1", latest="0.18.0")) 1XYZ0123
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
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 )
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
301 _seed_fresh_update_cache() 1hijklmn
302 result = CliRunner().invoke( 1hijklmn
303 command_with_toolkit,
304 [],
305 env={"FASTAPI_CLOUD_DISABLE_VERSION_CHECK": ""},
306 )
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
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
321 with get_rich_toolkit() as toolkit: 1opqrstu
322 toolkit.print("command output") 1opqrstu
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
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
338 check.start() 1vwxyzAB
339 message = check.get_update_message() 1vwxyzAB
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
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+,-./:;
356 check.start() 1+,-./:;
357 message = check.get_update_message() 1+,-./:;
359 assert message is None 1+,-./:;
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
369 monkeypatch.setattr( 1CDEFGHI
370 "fastapi_cloud_cli.utils.version_check.check_for_update",
371 broken_check,
372 )
373 check = BackgroundVersionCheck() 1CDEFGHI
375 check.start() 1CDEFGHI
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 )