import logging import os import shutil from aiohttp import ClientError from aiohttp import web import pytest from py.routes import update_routes def _fake_request(body=None, query_params=None): from multidict import MultiDict q = MultiDict(query_params or {}) req = type("Req", (), { "has_body": body is not None, "match_info": {}, "rel_url": type("U", (), {"query": q})(), "query": q, "app": {}, })() async def _json(): return body or {} req.json = _json return req class OfflineDownloader: async def make_request(self, *_, **__): return False, "Cannot connect to host" class RaisingDownloader: async def make_request(self, *_, **__): raise ClientError("offline") async def _stub_downloader(instance): return instance @pytest.mark.asyncio async def test_get_remote_version_offline_logs_without_traceback(monkeypatch, caplog): caplog.set_level(logging.WARNING) monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(OfflineDownloader())) version, changelog, releases = await update_routes.UpdateRoutes._get_remote_version() assert version == "v0.0.0" assert changelog == [] assert releases == [] assert "Failed to fetch GitHub releases" in caplog.text assert "Cannot connect to host" in caplog.text assert "Traceback" not in caplog.text @pytest.mark.asyncio async def test_get_remote_version_network_error_logs_warning(monkeypatch, caplog): caplog.set_level(logging.WARNING) monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(RaisingDownloader())) version, changelog, releases = await update_routes.UpdateRoutes._get_remote_version() assert version == "v0.0.0" assert changelog == [] assert releases == [] assert "Unable to reach GitHub for release info" in caplog.text assert "Traceback" not in caplog.text @pytest.mark.asyncio async def test_get_nightly_version_network_error_logs_warning(monkeypatch, caplog): caplog.set_level(logging.WARNING) monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(RaisingDownloader())) version, changelog, behind_by, commit_date = await update_routes.UpdateRoutes._get_nightly_version() assert version == "main" assert changelog == [] assert behind_by == 0 assert commit_date == "" assert "Unable to reach GitHub for nightly version" in caplog.text assert "Traceback" not in caplog.text def test_clean_excludes_covers_user_data_dirs(): """git clean must receive -e excludes for every user-managed dir.""" excludes = update_routes._clean_excludes() assert "-e" in excludes # at least one exclude flag present for name in update_routes._PRESERVE_DIRS: assert name in excludes assert f"{name}/**" in excludes @pytest.mark.asyncio async def test_perform_git_update_preserves_user_dirs(monkeypatch, tmp_path): """``git clean`` must be called with -e excludes for user data dirs. Regression test for portable-mode updates wiping wildcards/, stats/, backups/, etc. because ``git clean -fd`` removed untracked, non-ignored directories. """ calls = [] class FakeGit: def reset(self, *args, **kwargs): calls.append(("reset", args)) def clean(self, *args, **kwargs): calls.append(("clean", args)) def checkout(self, *args, **kwargs): calls.append(("checkout", args)) class FakeRemote: def fetch(self): calls.append(("fetch", ())) def pull(self, *args, **kwargs): calls.append(("pull", args)) class FakeRemotes: origin = FakeRemote() class FakeCommit: hexsha = "abcdef123456" class FakeHeads: def __getitem__(self, name): class Head: def checkout(self_inner): calls.append(("head-checkout", (name,))) return Head() class FakeBranches: names = ["main"] def __iter__(self): class B: name = "main" return iter([B()]) class FakeRepo: def __init__(self, path): calls.append(("repo", (path,))) git = FakeGit() remotes = FakeRemotes() head = type("H", (), {"commit": FakeCommit()})() branches = FakeBranches() heads = FakeHeads() def create_head(self, name, ref): calls.append(("create_head", (name, ref))) class FakeGitModule: class Repo: def __new__(cls, path): return FakeRepo(path) class exc: class GitError(Exception): pass import builtins real_import = builtins.__import__ def fake_import(name, *args, **kwargs): if name == "git": return FakeGitModule return real_import(name, *args, **kwargs) monkeypatch.setattr(builtins, "__import__", fake_import) success, version = await update_routes.UpdateRoutes._perform_git_update( str(tmp_path), nightly=True ) assert success is True clean_calls = [c for c in calls if c[0] == "clean"] assert len(clean_calls) == 1 clean_args = clean_calls[0][1] # Every preserved dir must be excluded via -e for name in update_routes._PRESERVE_DIRS: assert name in clean_args, f"{name} missing from git clean excludes" assert f"{name}/**" in clean_args, f"{name}/** missing from git clean excludes" # Ensure there's an -e before each name occurrence idx = clean_args.index(name) assert clean_args[idx - 1] == "-e" @pytest.mark.asyncio async def test_perform_git_update_stable_preserves_user_dirs(monkeypatch, tmp_path): """Stable (tag) update path must also pass -e excludes to git clean.""" calls = [] class FakeGit: def reset(self, *args, **kwargs): calls.append(("reset", args)) def clean(self, *args, **kwargs): calls.append(("clean", args)) def checkout(self, *args, **kwargs): calls.append(("checkout", args)) class FakeRemote: def fetch(self): calls.append(("fetch", ())) class FakeRemotes: origin = FakeRemote() class FakeCommit: committed_datetime = "2026-01-01" class FakeTag: name = "v9.9.9" commit = FakeCommit() class FakeRepo: def __init__(self, path): calls.append(("repo", (path,))) git = FakeGit() remotes = FakeRemotes() tags = [FakeTag()] class FakeGitModule: class Repo: def __new__(cls, path): return FakeRepo(path) class exc: class GitError(Exception): pass import builtins real_import = builtins.__import__ def fake_import(name, *args, **kwargs): if name == "git": return FakeGitModule return real_import(name, *args, **kwargs) monkeypatch.setattr(builtins, "__import__", fake_import) success, version = await update_routes.UpdateRoutes._perform_git_update( str(tmp_path), nightly=False ) assert success is True assert version == "v9.9.9" clean_calls = [c for c in calls if c[0] == "clean"] assert len(clean_calls) == 1 clean_args = clean_calls[0][1] for name in update_routes._PRESERVE_DIRS: assert name in clean_args, f"{name} missing from git clean excludes (stable)" def test_init_git_repo_creates_valid_repo(tmp_path, monkeypatch): if not shutil.which("git"): pytest.skip("git executable not found") plugin_root = tmp_path / "plugin" plugin_root.mkdir() (plugin_root / ".tracking").write_text("pyproject.toml") (plugin_root / "settings.json").write_text('{"some": "value"}') try: success, version = update_routes.UpdateRoutes._init_git_repo(str(plugin_root)) except Exception as e: pytest.skip(f"Network unavailable for git fetch: {e}") assert success is True assert version.startswith("main-") assert len(version) > len("main-") assert (plugin_root / ".git").is_dir() assert not (plugin_root / ".tracking").exists() assert (plugin_root / "settings.json").exists() assert (plugin_root / "pyproject.toml").exists() @pytest.mark.asyncio async def test_switch_channel_invalid_channel_returns_error(): req = _fake_request({"channel": "bad_channel"}) resp = await update_routes.UpdateRoutes.switch_channel(req) data = _raw_body(resp) assert not data["success"] assert "Invalid channel" in data["error"] @pytest.mark.asyncio async def test_switch_channel_to_nightly_without_git_inits_repo(monkeypatch, tmp_path): routes_file = tmp_path / "py" / "routes" / "update_routes.py" routes_file.parent.mkdir(parents=True) routes_file.write_text("") monkeypatch.setattr(update_routes, "__file__", str(routes_file)) monkeypatch.setattr(update_routes, "ensure_settings_file", lambda logger: str(tmp_path / "settings.json")) monkeypatch.setattr( update_routes.UpdateRoutes, "_init_git_repo", staticmethod(lambda plugin_root: (True, "main-fedcba9")), ) req = _fake_request({"channel": "nightly"}) resp = await update_routes.UpdateRoutes.switch_channel(req) data = _raw_body(resp) assert data["success"] is True assert data["channel"] == "nightly" assert data["new_version"] == "main-fedcba9" @pytest.mark.asyncio async def test_switch_channel_to_nightly_with_git_calls_git_update(monkeypatch, tmp_path): routes_file = tmp_path / "py" / "routes" / "update_routes.py" routes_file.parent.mkdir(parents=True) routes_file.write_text("") monkeypatch.setattr(update_routes, "__file__", str(routes_file)) monkeypatch.setattr(update_routes, "ensure_settings_file", lambda logger: str(tmp_path / "settings.json")) (tmp_path / ".git").mkdir() async def _fake_git_update(*args, **kwargs): return True, "main-1111111" monkeypatch.setattr( update_routes.UpdateRoutes, "_perform_git_update", _fake_git_update ) req = _fake_request({"channel": "nightly"}) resp = await update_routes.UpdateRoutes.switch_channel(req) data = _raw_body(resp) assert data["success"] is True assert data["channel"] == "nightly" assert data["new_version"] == "main-1111111" @pytest.mark.asyncio async def test_switch_channel_to_release_with_git_downloads_zip(monkeypatch, tmp_path): routes_file = tmp_path / "py" / "routes" / "update_routes.py" routes_file.parent.mkdir(parents=True) routes_file.write_text("") monkeypatch.setattr(update_routes, "__file__", str(routes_file)) monkeypatch.setattr(update_routes, "ensure_settings_file", lambda logger: str(tmp_path / "settings.json")) (tmp_path / ".git").mkdir() async def _fake_zip(*args, **kwargs): return True, "v9.9.9" monkeypatch.setattr( update_routes.UpdateRoutes, "_download_and_replace_zip", _fake_zip ) req = _fake_request({"channel": "release"}) resp = await update_routes.UpdateRoutes.switch_channel(req) data = _raw_body(resp) assert data["success"] is True assert data["channel"] == "release" assert data["new_version"] == "v9.9.9" @pytest.mark.asyncio async def test_switch_channel_to_release_without_git_still_downloads_zip(monkeypatch, tmp_path): routes_file = tmp_path / "py" / "routes" / "update_routes.py" routes_file.parent.mkdir(parents=True) routes_file.write_text("") monkeypatch.setattr(update_routes, "__file__", str(routes_file)) monkeypatch.setattr(update_routes, "ensure_settings_file", lambda logger: str(tmp_path / "settings.json")) async def _fake_zip(*args, **kwargs): return True, "v2.0.0" monkeypatch.setattr( update_routes.UpdateRoutes, "_download_and_replace_zip", _fake_zip ) req = _fake_request({"channel": "release"}) resp = await update_routes.UpdateRoutes.switch_channel(req) data = _raw_body(resp) assert data["success"] is True assert data["channel"] == "release" assert data["new_version"] == "v2.0.0" class _NightlyDownloader: """Returns a fake main-branch commit AND a compare response.""" commit_sha = "7777777" commit_msg = "test: add nightly feature" commit_date = "2026-07-27T12:00:00Z" behind_by = 5 async def make_request(self, method, url, **kwargs): if "/compare/" in url: return True, {"behind_by": self.behind_by} return True, { "sha": self.commit_sha, "commit": { "message": self.commit_msg, "committer": {"date": self.commit_date}, }, } @pytest.mark.asyncio async def test_get_nightly_version_parses_behind_by(monkeypatch): monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(_NightlyDownloader())) version, changelog, behind_by, commit_date = await update_routes.UpdateRoutes._get_nightly_version( local_hash="abc1234" ) assert version == "main-7777777" assert behind_by == 5 assert commit_date == "2026-07-27" assert len(changelog) == 1 assert changelog[0] == "test: add nightly feature" class _AheadCompareDownloader: """Fake compare API response with status='ahead' (main is ahead of local).""" commit_sha = "9999999" commit_msg = "latest commit" commit_date = "2026-07-28T00:00:00Z" ahead_by = 3 async def make_request(self, method, url, **kwargs): if "/compare/" in url: return True, {"status": "ahead", "ahead_by": self.ahead_by, "behind_by": 0} return True, { "sha": self.commit_sha, "commit": { "message": self.commit_msg, "committer": {"date": self.commit_date}, }, } @pytest.mark.asyncio async def test_get_nightly_version_reads_ahead_by_when_ahead(monkeypatch): """compare/{local}...main returns status='ahead' → read ahead_by.""" monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(_AheadCompareDownloader())) version, changelog, behind_by, commit_date = await update_routes.UpdateRoutes._get_nightly_version( local_hash="oldhash" ) assert version == "main-9999999" assert behind_by == 3 assert commit_date == "2026-07-28" class _DivergedCompareDownloader: """Fake compare API response with status='diverged' (both have unique commits).""" commit_sha = "aaaaaaa" commit_msg = "diverged test" commit_date = "2026-07-29T00:00:00Z" async def make_request(self, method, url, **kwargs): if "/compare/" in url: return True, {"status": "diverged", "ahead_by": 5, "behind_by": 2} return True, { "sha": self.commit_sha, "commit": { "message": self.commit_msg, "committer": {"date": self.commit_date}, }, } @pytest.mark.asyncio async def test_get_nightly_version_reads_ahead_by_when_diverged(monkeypatch): """compare/{local}...main returns status='diverged' → read ahead_by (remote ahead).""" monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(_DivergedCompareDownloader())) version, changelog, behind_by, commit_date = await update_routes.UpdateRoutes._get_nightly_version( local_hash="divhash" ) assert behind_by == 5 class _CheckUpdatesDownloader: """Fake downloader returning both a release list and a nightly commit + compare.""" commit_sha = "8888888" commit_date = "2026-07-28T00:00:00Z" async def make_request(self, method, url, **kwargs): if "/releases" in url: return True, [ { "tag_name": "v3.0.0", "body": "- Feature A\n- Feature B", "published_at": "2026-07-20T00:00:00Z", } ] if "/compare/" in url: return True, {"behind_by": 3} return True, { "sha": self.commit_sha + "0" * 33, "commit": { "message": "latest commit", "committer": {"date": self.commit_date}, }, } @pytest.mark.asyncio async def test_check_updates_nightly_response_includes_behind_and_date(monkeypatch, tmp_path): monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(_CheckUpdatesDownloader())) monkeypatch.setattr( update_routes.UpdateRoutes, "_get_local_version", staticmethod(lambda: "v1.0.0"), ) monkeypatch.setattr( update_routes.UpdateRoutes, "_get_git_info", staticmethod(lambda: { "commit_hash": "abc1234", "short_hash": "abc1234", "branch": "main", "commit_date": "2026-01-01", }), ) routes_file = tmp_path / "py" / "routes" / "update_routes.py" routes_file.parent.mkdir(parents=True) routes_file.write_text("") monkeypatch.setattr(update_routes, "__file__", str(routes_file)) (tmp_path / ".git").mkdir() req = _fake_request(query_params={"nightly": "true"}) resp = await update_routes.UpdateRoutes.check_updates(req) data = _raw_body(resp) assert data["success"] is True assert data["nightly"] is True assert data["has_git"] is True assert data["behind_by"] == 3 assert data["commit_date"] == "2026-07-28" assert data["latest_version"] == "main-8888888" assert isinstance(data["releases"], list) assert len(data["releases"]) == 1 assert data["releases"][0]["version"] == "v3.0.0" def _raw_body(response): import json return json.loads(response._body.decode())