"""Tests for `muse hub` CLI commands — connect, status, disconnect, ping. All network calls are mocked — no real HTTP traffic occurs. The identity store is isolated per test using a tmp_path override. """ from __future__ import annotations import io import json import pathlib import unittest.mock import urllib.error import urllib.request import urllib.response import pytest from tests.cli_test_helper import CliRunner from muse._version import __version__ cli = None # argparse migration — CliRunner ignores this arg from muse.cli.commands.hub import _hub_hostname, _normalise_url, _ping_hub from muse.cli.config import get_hub_url, set_hub_url from muse.core.identity import IdentityEntry, save_identity runner = CliRunner() # --------------------------------------------------------------------------- # Fixture: minimal Muse repo # --------------------------------------------------------------------------- @pytest.fixture() def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: """Minimal .muse/ repo; hub tests don't need commits.""" muse_dir = tmp_path / ".muse" (muse_dir / "refs" / "heads").mkdir(parents=True) (muse_dir / "objects").mkdir() (muse_dir / "commits").mkdir() (muse_dir / "snapshots").mkdir() (muse_dir / "repo.json").write_text( json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "midi"}) ) (muse_dir / "HEAD").write_text("ref: refs/heads/main\n") monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path)) monkeypatch.chdir(tmp_path) # Redirect the identity store to tmp_path so tests never touch ~/.muse/ fake_identity_dir = tmp_path / "fake_home" / ".muse" fake_identity_dir.mkdir(parents=True) fake_identity_file = fake_identity_dir / "identity.toml" monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_identity_dir) monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", fake_identity_file) return tmp_path # --------------------------------------------------------------------------- # Unit tests for pure helper functions # --------------------------------------------------------------------------- class TestNormaliseUrl: def test_bare_hostname_gets_https(self) -> None: assert _normalise_url("musehub.ai") == "https://musehub.ai" def test_https_url_unchanged(self) -> None: assert _normalise_url("https://musehub.ai") == "https://musehub.ai" def test_trailing_slash_stripped(self) -> None: assert _normalise_url("https://musehub.ai/") == "https://musehub.ai" def test_http_url_raises(self) -> None: with pytest.raises(ValueError, match="Insecure"): _normalise_url("http://musehub.ai") def test_http_suggests_https(self) -> None: with pytest.raises(ValueError, match="https://"): _normalise_url("http://musehub.ai") def test_whitespace_stripped(self) -> None: assert _normalise_url(" https://musehub.ai ") == "https://musehub.ai" class TestHubHostname: def test_extracts_hostname_from_https_url(self) -> None: assert _hub_hostname("https://musehub.ai/repos/r1") == "musehub.ai" def test_bare_hostname(self) -> None: assert _hub_hostname("musehub.ai") == "musehub.ai" def test_strips_path(self) -> None: assert _hub_hostname("https://musehub.ai/deep/path") == "musehub.ai" def test_preserves_port(self) -> None: assert _hub_hostname("https://musehub.ai:8443") == "musehub.ai:8443" class TestPingHub: def test_2xx_returns_true(self) -> None: mock_resp = unittest.mock.MagicMock() mock_resp.status = 200 mock_resp.__enter__ = lambda s: s mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False) with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", return_value=mock_resp): ok, msg = _ping_hub("https://musehub.ai") assert ok is True assert "200" in msg def test_5xx_returns_false(self) -> None: mock_resp = unittest.mock.MagicMock() mock_resp.status = 503 mock_resp.__enter__ = lambda s: s mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False) with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", return_value=mock_resp): ok, msg = _ping_hub("https://musehub.ai") assert ok is False def test_http_error_returns_false(self) -> None: err = urllib.error.HTTPError("https://musehub.ai/health", 401, "Unauthorized", {}, io.BytesIO(b"Unauthorized")) with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", side_effect=err): ok, msg = _ping_hub("https://musehub.ai") assert ok is False assert "401" in msg def test_url_error_returns_false(self) -> None: err = urllib.error.URLError("name resolution failure") with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", side_effect=err): ok, msg = _ping_hub("https://musehub.ai") assert ok is False def test_timeout_error_returns_false(self) -> None: with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", side_effect=TimeoutError()): ok, msg = _ping_hub("https://musehub.ai") assert ok is False assert "timed out" in msg def test_os_error_returns_false(self) -> None: with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", side_effect=OSError("network down")): ok, msg = _ping_hub("https://musehub.ai") assert ok is False def test_health_endpoint_used(self) -> None: calls: list[str] = [] def _fake_open(req: urllib.request.Request, timeout: int = 0) -> urllib.response.addinfourl: calls.append(req.full_url) raise urllib.error.URLError("stop") with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", side_effect=_fake_open): _ping_hub("https://musehub.ai") assert calls and calls[0] == "https://musehub.ai/health" # --------------------------------------------------------------------------- # hub connect # --------------------------------------------------------------------------- class TestHubConnect: def test_connect_bare_hostname(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["hub", "connect", "musehub.ai"]) assert result.exit_code == 0 assert "Connected" in result.output def test_connect_stores_https_url(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["hub", "connect", "musehub.ai"]) stored = get_hub_url(repo) assert stored == "https://musehub.ai" def test_connect_https_url_directly(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["hub", "connect", "https://musehub.ai"]) assert result.exit_code == 0 assert get_hub_url(repo) == "https://musehub.ai" def test_connect_http_rejected(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["hub", "connect", "http://musehub.ai"]) assert result.exit_code != 0 assert "Insecure" in result.output or "rejected" in result.output def test_connect_warns_on_hub_switch(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["hub", "connect", "https://hub1.example.com"]) result = runner.invoke(cli, ["hub", "connect", "https://hub2.example.com"]) assert result.exit_code == 0 assert "hub1.example.com" in result.output or "Switching" in result.output def test_connect_shows_identity_if_already_logged_in(self, repo: pathlib.Path) -> None: entry: IdentityEntry = {"type": "human", "handle": "Alice", "key_path": "/k"} save_identity("https://musehub.ai", entry) result = runner.invoke(cli, ["hub", "connect", "https://musehub.ai"]) assert result.exit_code == 0 assert "Alice" in result.output or "human" in result.output def test_connect_prompts_login_when_no_identity(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["hub", "connect", "https://musehub.ai"]) assert result.exit_code == 0 assert "muse auth" in result.output def test_connect_fails_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) monkeypatch.delenv("MUSE_REPO_ROOT", raising=False) result = runner.invoke(cli, ["hub", "connect", "https://musehub.ai"]) assert result.exit_code != 0 # --------------------------------------------------------------------------- # hub status # --------------------------------------------------------------------------- class TestHubStatus: def _setup_hub(self, repo: pathlib.Path) -> None: set_hub_url("https://musehub.ai", repo) def test_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["hub", "status"]) assert result.exit_code != 0 def test_hub_url_shown(self, repo: pathlib.Path) -> None: self._setup_hub(repo) result = runner.invoke(cli, ["hub", "status"]) assert result.exit_code == 0 assert "musehub.ai" in result.output def test_not_authenticated_shown(self, repo: pathlib.Path) -> None: self._setup_hub(repo) result = runner.invoke(cli, ["hub", "status"]) assert "not authenticated" in result.output or "auth" in result.output def test_identity_fields_shown_when_logged_in(self, repo: pathlib.Path) -> None: self._setup_hub(repo) entry: IdentityEntry = {"type": "agent", "handle": "bot", "fingerprint": "agt_001", "key_path": "/k"} save_identity("https://musehub.ai", entry) result = runner.invoke(cli, ["hub", "status"]) assert "agent" in result.output assert "bot" in result.output def test_json_output_structure(self, repo: pathlib.Path) -> None: self._setup_hub(repo) result = runner.invoke(cli, ["hub", "status", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert "hub_url" in data assert "hostname" in data assert "authenticated" in data def test_json_output_with_identity(self, repo: pathlib.Path) -> None: self._setup_hub(repo) entry: IdentityEntry = {"type": "human", "handle": "Alice", "fingerprint": "usr_1", "key_path": "/k"} save_identity("https://musehub.ai", entry) result = runner.invoke(cli, ["hub", "status", "--json"]) data = json.loads(result.output) assert data["authenticated"] is True assert data["identity_type"] == "human" assert data["identity_name"] == "Alice" # --------------------------------------------------------------------------- # hub disconnect # --------------------------------------------------------------------------- class TestHubDisconnect: def test_disconnect_clears_hub_url(self, repo: pathlib.Path) -> None: set_hub_url("https://musehub.ai", repo) result = runner.invoke(cli, ["hub", "disconnect"]) assert result.exit_code == 0 assert get_hub_url(repo) is None def test_disconnect_shows_hostname(self, repo: pathlib.Path) -> None: set_hub_url("https://musehub.ai", repo) result = runner.invoke(cli, ["hub", "disconnect"]) assert "musehub.ai" in result.output def test_disconnect_nothing_to_do(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["hub", "disconnect"]) assert result.exit_code == 0 assert "nothing" in result.output.lower() or "No hub" in result.output def test_disconnect_preserves_identity(self, repo: pathlib.Path) -> None: """Credentials in identity.toml must survive hub disconnect.""" set_hub_url("https://musehub.ai", repo) entry: IdentityEntry = {"type": "human", "handle": "alice", "key_path": "/k"} save_identity("https://musehub.ai", entry) runner.invoke(cli, ["hub", "disconnect"]) from muse.core.identity import load_identity assert load_identity("https://musehub.ai") is not None def test_disconnect_fails_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) monkeypatch.delenv("MUSE_REPO_ROOT", raising=False) result = runner.invoke(cli, ["hub", "disconnect"]) assert result.exit_code != 0 # --------------------------------------------------------------------------- # hub ping # --------------------------------------------------------------------------- class TestHubPing: def _setup_hub(self, repo: pathlib.Path) -> None: set_hub_url("https://musehub.ai", repo) def test_ping_success(self, repo: pathlib.Path) -> None: self._setup_hub(repo) mock_resp = unittest.mock.MagicMock() mock_resp.status = 200 mock_resp.__enter__ = lambda s: s mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False) with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", return_value=mock_resp): result = runner.invoke(cli, ["hub", "ping"]) assert result.exit_code == 0 assert "200" in result.output or "OK" in result.output.upper() def test_ping_failure_exits_nonzero(self, repo: pathlib.Path) -> None: self._setup_hub(repo) err = urllib.error.URLError("no route to host") with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", side_effect=err): result = runner.invoke(cli, ["hub", "ping"]) assert result.exit_code != 0 def test_ping_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["hub", "ping"]) assert result.exit_code != 0 def test_ping_fails_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) monkeypatch.delenv("MUSE_REPO_ROOT", raising=False) result = runner.invoke(cli, ["hub", "ping"]) assert result.exit_code != 0 # --------------------------------------------------------------------------- # _enrich_hub_url_from_remote # --------------------------------------------------------------------------- class TestEnrichHubUrlFromRemote: """_enrich_hub_url_from_remote appends owner/slug from a matching remote.""" def _write_remote(self, repo: pathlib.Path, name: str, url: str) -> None: from muse.cli.config import set_remote set_remote(name, url, repo) def test_enriches_bare_hub_url_with_owner_slug(self, repo: pathlib.Path) -> None: from muse.cli.commands.hub import _enrich_hub_url_from_remote self._write_remote(repo, "local", "http://localhost:10003/gabriel/muse") result = _enrich_hub_url_from_remote("http://localhost:10003") assert result == "http://localhost:10003/gabriel/muse" def test_leaves_already_enriched_url_unchanged(self, repo: pathlib.Path) -> None: from muse.cli.commands.hub import _enrich_hub_url_from_remote self._write_remote(repo, "local", "http://localhost:10003/gabriel/muse") result = _enrich_hub_url_from_remote("http://localhost:10003/gabriel/muse") assert result == "http://localhost:10003/gabriel/muse" def test_returns_bare_url_when_no_matching_remote(self, repo: pathlib.Path) -> None: from muse.cli.commands.hub import _enrich_hub_url_from_remote # remote is on a different host — no match self._write_remote(repo, "staging", "http://otherhost:10003/gabriel/muse") result = _enrich_hub_url_from_remote("http://localhost:10003") assert result == "http://localhost:10003" def test_returns_bare_url_when_no_remotes_configured(self, repo: pathlib.Path) -> None: from muse.cli.commands.hub import _enrich_hub_url_from_remote result = _enrich_hub_url_from_remote("http://localhost:10003") assert result == "http://localhost:10003" def test_returns_bare_url_outside_repo( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) monkeypatch.delenv("MUSE_REPO_ROOT", raising=False) from muse.cli.commands.hub import _enrich_hub_url_from_remote result = _enrich_hub_url_from_remote("http://localhost:10003") assert result == "http://localhost:10003" # --------------------------------------------------------------------------- # muse hub issue read (renamed from "get") # --------------------------------------------------------------------------- class TestHubIssueReadCommand: """'muse hub issue read' is the canonical verb — 'get' is gone.""" def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None: set_hub_url(hub_url, repo) identity = IdentityEntry( type="human", handle="gabriel", key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"), algorithm="ed25519", fingerprint="deadbeef", ) save_identity(hub_url, identity) def test_issue_read_subcommand_exists(self, repo: pathlib.Path) -> None: """'read' must be a valid subcommand — exit code must not be 2 (unknown subcommand).""" self._setup_auth(repo, "http://localhost:10003/gabriel/muse") mock_resp = {"number": 1, "title": "Test", "state": "open", "body": ""} with unittest.mock.patch( "muse.cli.commands.hub._hub_api", return_value=mock_resp ): result = runner.invoke(cli, ["hub", "issue", "read", "1"]) # A valid subcommand that fails for other reasons (auth, network) gives != 2 # The key assertion: exit_code 2 means "unrecognised subcommand" — that must not happen. assert result.exit_code != 2, f"'read' is not a recognised subcommand: {result.output}" def test_issue_get_subcommand_removed(self, repo: pathlib.Path) -> None: """'get' must no longer be accepted.""" self._setup_auth(repo, "http://localhost:10003/gabriel/muse") result = runner.invoke(cli, ["hub", "issue", "get", "1"]) assert result.exit_code != 0 # --------------------------------------------------------------------------- # muse hub proposal read (renamed from "view") # --------------------------------------------------------------------------- class TestHubProposalReadCommand: """'muse hub proposal read' is the canonical verb — 'view' is gone.""" def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None: set_hub_url(hub_url, repo) identity = IdentityEntry( type="human", handle="gabriel", key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"), algorithm="ed25519", fingerprint="deadbeef", ) save_identity(hub_url, identity) def test_proposal_read_subcommand_exists(self, repo: pathlib.Path) -> None: """'read' must be a valid subcommand — exit code must not be 2.""" self._setup_auth(repo, "http://localhost:10003/gabriel/muse") mock_proposal = { "proposalId": "abc123", "number": 1, "title": "Test", "state": "open", "headBranch": "feat/x", "baseBranch": "dev", "author": "gabriel", "createdAt": "2026-04-09T00:00:00Z", "body": "", } with unittest.mock.patch( "muse.cli.commands.hub._hub_api", return_value={"proposals": [mock_proposal]} ): result = runner.invoke(cli, ["hub", "proposal", "read", "abc123"]) assert result.exit_code != 2, f"'read' is not a recognised subcommand: {result.output}" def test_proposal_view_subcommand_removed(self, repo: pathlib.Path) -> None: """'view' must no longer be accepted.""" self._setup_auth(repo, "http://localhost:10003/gabriel/muse") result = runner.invoke(cli, ["hub", "proposal", "view", "abc123"]) assert result.exit_code != 0 # --------------------------------------------------------------------------- # muse hub issue update (renamed from "edit") # --------------------------------------------------------------------------- class TestHubIssueUpdateCommand: """'muse hub issue update' is the canonical verb — 'edit' is gone.""" def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None: set_hub_url(hub_url, repo) identity = IdentityEntry( type="human", handle="gabriel", key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"), algorithm="ed25519", fingerprint="deadbeef", ) save_identity(hub_url, identity) def test_issue_update_subcommand_exists(self, repo: pathlib.Path) -> None: """'update' must be a valid subcommand — exit code must not be 2 (unknown subcommand).""" self._setup_auth(repo, "http://localhost:10003/gabriel/muse") mock_resp = {"number": 1, "title": "Updated", "state": "open", "body": "new body"} with unittest.mock.patch( "muse.cli.commands.hub._hub_api", return_value=mock_resp ): result = runner.invoke(cli, ["hub", "issue", "update", "1", "--body", "new body"]) assert result.exit_code != 2, f"'update' is not a recognised subcommand: {result.output}" def test_issue_edit_subcommand_removed(self, repo: pathlib.Path) -> None: """'edit' must no longer be accepted.""" self._setup_auth(repo, "http://localhost:10003/gabriel/muse") result = runner.invoke(cli, ["hub", "issue", "edit", "1", "--body", "x"]) assert result.exit_code != 0 # --------------------------------------------------------------------------- # muse hub repo update (renamed from "settings") # --------------------------------------------------------------------------- class TestHubRepoUpdateCommand: """'muse hub repo update' is the canonical verb — 'settings' is gone.""" def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None: set_hub_url(hub_url, repo) identity = IdentityEntry( type="human", handle="gabriel", key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"), algorithm="ed25519", fingerprint="deadbeef", ) save_identity(hub_url, identity) def test_repo_update_subcommand_exists(self, repo: pathlib.Path) -> None: """'update' must be a valid repo subcommand — exit code must not be 2.""" self._setup_auth(repo, "http://localhost:10003/gabriel/muse") mock_resp = {"repoId": "abc", "name": "muse", "owner": "gabriel"} with unittest.mock.patch( "muse.cli.commands.hub._hub_api", return_value=mock_resp ): result = runner.invoke(cli, ["hub", "repo", "update", "--description", "x"]) assert result.exit_code != 2, f"'update' is not a recognised subcommand: {result.output}" def test_repo_settings_subcommand_removed(self, repo: pathlib.Path) -> None: """'settings' must no longer be accepted.""" self._setup_auth(repo, "http://localhost:10003/gabriel/muse") result = runner.invoke(cli, ["hub", "repo", "settings"]) assert result.exit_code != 0 # --------------------------------------------------------------------------- # muse hub repo transfer-ownership (renamed from "transfer") # --------------------------------------------------------------------------- class TestHubRepoTransferOwnershipCommand: """'muse hub repo transfer-ownership' is the canonical verb — 'transfer' is gone.""" def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None: set_hub_url(hub_url, repo) identity = IdentityEntry( type="human", handle="gabriel", key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"), algorithm="ed25519", fingerprint="deadbeef", ) save_identity(hub_url, identity) def test_repo_transfer_ownership_subcommand_exists(self, repo: pathlib.Path) -> None: """'transfer-ownership' must be a valid repo subcommand — exit code must not be 2.""" self._setup_auth(repo, "http://localhost:10003/gabriel/muse") mock_resp = {"repoId": "abc", "name": "muse", "owner": "bob"} with unittest.mock.patch( "muse.cli.commands.hub._hub_api", return_value=mock_resp ): result = runner.invoke(cli, ["hub", "repo", "transfer-ownership", "--new-owner", "bob"]) assert result.exit_code != 2, f"'transfer-ownership' is not a recognised subcommand: {result.output}" def test_repo_transfer_subcommand_removed(self, repo: pathlib.Path) -> None: """'transfer' must no longer be accepted.""" self._setup_auth(repo, "http://localhost:10003/gabriel/muse") result = runner.invoke(cli, ["hub", "repo", "transfer", "--new-owner", "bob"]) assert result.exit_code != 0 # --------------------------------------------------------------------------- # muse hub collaborator update-permission (renamed from "update") # --------------------------------------------------------------------------- class TestHubCollaboratorUpdatePermissionCommand: """'muse hub collaborator update-permission' is the canonical verb — 'update' is gone.""" def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None: set_hub_url(hub_url, repo) identity = IdentityEntry( type="human", handle="gabriel", key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"), algorithm="ed25519", fingerprint="deadbeef", ) save_identity(hub_url, identity) def test_collaborator_update_permission_subcommand_exists(self, repo: pathlib.Path) -> None: """'update-permission' must be a valid collaborator subcommand — exit code must not be 2.""" self._setup_auth(repo, "http://localhost:10003/gabriel/muse") mock_resp = {"handle": "carol", "permission": "admin"} with unittest.mock.patch( "muse.cli.commands.hub._hub_api", return_value=mock_resp ): result = runner.invoke( cli, ["hub", "collaborator", "update-permission", "carol", "--permission", "admin"] ) assert result.exit_code != 2, f"'update-permission' is not a recognised subcommand: {result.output}" def test_collaborator_update_subcommand_removed(self, repo: pathlib.Path) -> None: """'update' must no longer be accepted as a collaborator subcommand.""" self._setup_auth(repo, "http://localhost:10003/gabriel/muse") result = runner.invoke( cli, ["hub", "collaborator", "update", "carol", "--permission", "admin"] ) assert result.exit_code != 0 # --------------------------------------------------------------------------- # muse hub repo list # --------------------------------------------------------------------------- class TestHubRepoListCommand: """'muse hub repo list' lists repos for the authenticated user.""" def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None: set_hub_url(hub_url, repo) identity = IdentityEntry( type="human", handle="gabriel", key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"), algorithm="ed25519", fingerprint="deadbeef", ) save_identity(hub_url, identity) def test_repo_list_subcommand_exists(self, repo: pathlib.Path) -> None: """'list' must be a valid repo subcommand — exit code must not be 2.""" self._setup_auth(repo, "http://localhost:10003/gabriel/muse") mock_resp = { "total": 1, "nextCursor": None, "repos": [ { "repoId": "abc", "name": "my-repo", "owner": "gabriel", "slug": "my-repo", "visibility": "public", "description": "Test repo", "tags": [], "defaultBranch": "main", "createdAt": "2026-01-01T00:00:00Z", "pushedAt": "", } ], } with unittest.mock.patch("muse.cli.commands.hub._hub_api", return_value=mock_resp): result = runner.invoke(cli, ["hub", "repo", "list"]) assert result.exit_code != 2, f"'list' is not a recognised subcommand: {result.output}" def test_repo_list_json_output_structure(self, repo: pathlib.Path) -> None: """--json emits total, next_cursor, and repos list to stdout.""" self._setup_auth(repo, "http://localhost:10003/gabriel/muse") mock_resp = { "total": 2, "nextCursor": None, "repos": [ { "repoId": "id1", "name": "alpha", "owner": "gabriel", "slug": "alpha", "visibility": "public", "description": "", "tags": [], "defaultBranch": "main", "createdAt": "2026-01-01T00:00:00Z", "pushedAt": "", }, { "repoId": "id2", "name": "beta", "owner": "gabriel", "slug": "beta", "visibility": "private", "description": "private repo", "tags": ["music"], "defaultBranch": "dev", "createdAt": "2026-01-02T00:00:00Z", "pushedAt": "", }, ], } with unittest.mock.patch("muse.cli.commands.hub._hub_api", return_value=mock_resp): result = runner.invoke(cli, ["hub", "repo", "list", "--json"]) assert result.exit_code == 0, result.output out = json.loads(result.output) assert out["total"] == 2 assert out["next_cursor"] is None assert len(out["repos"]) == 2 slugs = [r["slug"] for r in out["repos"]] assert "alpha" in slugs assert "beta" in slugs def test_repo_list_json_fields_present(self, repo: pathlib.Path) -> None: """Each repo in --json output has all documented fields.""" self._setup_auth(repo, "http://localhost:10003/gabriel/muse") mock_resp = { "total": 1, "nextCursor": None, "repos": [ { "repoId": "abc", "name": "check-fields", "owner": "gabriel", "slug": "check-fields", "visibility": "public", "description": "desc", "tags": ["a", "b"], "defaultBranch": "main", "createdAt": "2026-01-01T00:00:00Z", "pushedAt": "2026-03-01T00:00:00Z", } ], } with unittest.mock.patch("muse.cli.commands.hub._hub_api", return_value=mock_resp): result = runner.invoke(cli, ["hub", "repo", "list", "--json"]) assert result.exit_code == 0 repos = json.loads(result.output)["repos"] assert len(repos) == 1 for field in ("repo_id", "name", "owner", "slug", "visibility", "description", "tags", "default_branch", "created_at", "pushed_at"): assert field in repos[0], f"missing field: {field}" def test_repo_list_passes_limit_to_api(self, repo: pathlib.Path) -> None: """--limit is forwarded as a query parameter.""" self._setup_auth(repo, "http://localhost:10003/gabriel/muse") captured: list[str] = [] def fake_api(hub_url: str, identity: object, method: str, path: str, **kwargs: object) -> object: captured.append(path) return {"total": 0, "nextCursor": None, "repos": []} with unittest.mock.patch("muse.cli.commands.hub._hub_api", side_effect=fake_api): runner.invoke(cli, ["hub", "repo", "list", "--limit", "42", "--json"]) assert captured, "no API call made" assert "limit=42" in captured[0] def test_repo_list_empty_prints_no_repos(self, repo: pathlib.Path) -> None: """Empty repo list exits 0 and does not crash.""" self._setup_auth(repo, "http://localhost:10003/gabriel/muse") mock_resp = {"total": 0, "nextCursor": None, "repos": []} with unittest.mock.patch("muse.cli.commands.hub._hub_api", return_value=mock_resp): result = runner.invoke(cli, ["hub", "repo", "list"]) assert result.exit_code == 0 # --------------------------------------------------------------------------- # muse hub repo show # --------------------------------------------------------------------------- class TestHubRepoShowCommand: """'muse hub repo show' fetches metadata for a single repo.""" def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None: set_hub_url(hub_url, repo) identity = IdentityEntry( type="human", handle="gabriel", key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"), algorithm="ed25519", fingerprint="deadbeef", ) save_identity(hub_url, identity) _MOCK_REPO = { "repoId": "abc123", "name": "jazz-standards", "owner": "gabriel", "slug": "jazz-standards", "visibility": "public", "description": "A collection of jazz standards", "tags": ["music", "jazz"], "defaultBranch": "main", "cloneUrl": "http://localhost:10003/gabriel/jazz-standards", "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-02-01T00:00:00Z", "pushedAt": "2026-03-01T00:00:00Z", } def test_repo_show_subcommand_exists(self, repo: pathlib.Path) -> None: """'show' must be a valid repo subcommand — exit code must not be 2.""" self._setup_auth(repo, "http://localhost:10003/gabriel/muse") with unittest.mock.patch( "muse.cli.commands.hub._hub_api", return_value=self._MOCK_REPO ): result = runner.invoke(cli, ["hub", "repo", "show", "gabriel/jazz-standards"]) assert result.exit_code != 2, f"'show' is not a recognised subcommand: {result.output}" def test_repo_show_json_output_structure(self, repo: pathlib.Path) -> None: """--json emits all documented repo fields to stdout.""" self._setup_auth(repo, "http://localhost:10003/gabriel/muse") with unittest.mock.patch( "muse.cli.commands.hub._hub_api", return_value=self._MOCK_REPO ): result = runner.invoke( cli, ["hub", "repo", "show", "gabriel/jazz-standards", "--json"] ) assert result.exit_code == 0, result.output out = json.loads(result.output) assert out["repo_id"] == "abc123" assert out["slug"] == "jazz-standards" assert out["owner"] == "gabriel" assert out["visibility"] == "public" def test_repo_show_json_fields_present(self, repo: pathlib.Path) -> None: """All documented fields are present in --json output.""" self._setup_auth(repo, "http://localhost:10003/gabriel/muse") with unittest.mock.patch( "muse.cli.commands.hub._hub_api", return_value=self._MOCK_REPO ): result = runner.invoke( cli, ["hub", "repo", "show", "gabriel/jazz-standards", "--json"] ) assert result.exit_code == 0 out = json.loads(result.output) for field in ("repo_id", "name", "owner", "slug", "visibility", "description", "tags", "default_branch", "clone_url", "created_at", "updated_at", "pushed_at"): assert field in out, f"missing field: {field}" def test_repo_show_uses_owner_slug_url(self, repo: pathlib.Path) -> None: """OWNER/SLUG argument resolves via /api/{owner}/{slug} not /api/repos/{id}.""" self._setup_auth(repo, "http://localhost:10003/gabriel/muse") captured: list[str] = [] def fake_api(hub_url: str, identity: object, method: str, path: str, **kwargs: object) -> object: captured.append(path) return self._MOCK_REPO with unittest.mock.patch("muse.cli.commands.hub._hub_api", side_effect=fake_api): runner.invoke(cli, ["hub", "repo", "show", "gabriel/jazz-standards", "--json"]) assert captured, "no API call made" assert "/api/gabriel/jazz-standards" in captured[0] def test_repo_show_tags_preserved(self, repo: pathlib.Path) -> None: """Tags list is preserved intact in JSON output.""" self._setup_auth(repo, "http://localhost:10003/gabriel/muse") with unittest.mock.patch( "muse.cli.commands.hub._hub_api", return_value=self._MOCK_REPO ): result = runner.invoke( cli, ["hub", "repo", "show", "gabriel/jazz-standards", "--json"] ) assert result.exit_code == 0 out = json.loads(result.output) assert out["tags"] == ["music", "jazz"] class TestHubRepoDeleteCommand: """Tests for run_repo_delete with the new TARGET argument.""" def test_delete_by_uuid_calls_correct_endpoint(self) -> None: """run_repo_delete with a UUID target calls DELETE /api/repos/{uuid}.""" import argparse import unittest.mock as mock from muse.cli.commands.hub import run_repo_delete repo_id = "a3f2c9d1-0000-0000-0000-000000000001" with mock.patch("muse.cli.commands.hub._hub_api", return_value={}) as m_api, \ mock.patch("muse.cli.commands.hub._get_hub_and_identity", return_value=("http://localhost:10003", mock.MagicMock())): args = argparse.Namespace(target=repo_id, yes=True, hub=None, json_output=True) run_repo_delete(args) calls = [str(c) for c in m_api.call_args_list] assert any(f"/api/repos/{repo_id}" in c for c in calls), ( f"Expected DELETE /api/repos/{repo_id}, got: {calls}" ) def test_delete_by_owner_slug_resolves_then_deletes(self) -> None: """run_repo_delete with OWNER/SLUG fetches repo_id then DELETEs it.""" import argparse import unittest.mock as mock from muse.cli.commands.hub import run_repo_delete resolved_id = "b4e5d6f7-0000-0000-0000-000000000002" get_resp = {"repoId": resolved_id, "name": "my-repo", "owner": "gabriel"} with mock.patch("muse.cli.commands.hub._hub_api", side_effect=[get_resp, {}]) as m_api, \ mock.patch("muse.cli.commands.hub._get_hub_and_identity", return_value=("http://localhost:10003", mock.MagicMock())): args = argparse.Namespace(target="gabriel/my-repo", yes=True, hub=None, json_output=True) run_repo_delete(args) calls = m_api.call_args_list assert len(calls) == 2 # First: GET to resolve owner/slug assert calls[0].args[2] == "GET" assert "/api/gabriel/my-repo" in calls[0].args[3] # Second: DELETE with resolved UUID assert calls[1].args[2] == "DELETE" assert f"/api/repos/{resolved_id}" in calls[1].args[3] def test_delete_without_yes_exits_nonzero_and_skips_api(self) -> None: """Without --yes, exits non-zero and never calls the API.""" import argparse import pytest import unittest.mock as mock from muse.cli.commands.hub import run_repo_delete with mock.patch("muse.cli.commands.hub._hub_api") as m_api, \ mock.patch("muse.cli.commands.hub._get_hub_and_identity", return_value=("http://localhost:10003", mock.MagicMock())): args = argparse.Namespace(target="gabriel/my-repo", yes=False, hub=None, json_output=False) with pytest.raises(SystemExit) as exc_info: run_repo_delete(args) assert exc_info.value.code != 0 m_api.assert_not_called() def test_delete_no_target_falls_back_to_config_resolution(self) -> None: """When target is None, repo_id is resolved from the current directory config.""" import argparse import unittest.mock as mock from muse.cli.commands.hub import run_repo_delete config_repo_id = "c5f6e7a8-0000-0000-0000-000000000003" with mock.patch("muse.cli.commands.hub._hub_api", return_value={}) as m_api, \ mock.patch("muse.cli.commands.hub._get_hub_and_identity", return_value=("http://localhost:10003", mock.MagicMock())), \ mock.patch("muse.cli.commands.hub._resolve_repo_id", return_value=config_repo_id): args = argparse.Namespace(target=None, yes=True, hub=None, json_output=True) run_repo_delete(args) calls = [str(c) for c in m_api.call_args_list] assert any(f"/api/repos/{config_repo_id}" in c for c in calls), ( f"Expected DELETE using config repo_id, got: {calls}" ) def test_delete_json_output_emits_structured_result(self) -> None: """--json flag emits {deleted: true, repo_id: ...} to stdout.""" import argparse import io import json as json_mod import sys import unittest.mock as mock from muse.cli.commands.hub import run_repo_delete repo_id = "d6g7h8i9-0000-0000-0000-000000000004" with mock.patch("muse.cli.commands.hub._hub_api", return_value={}), \ mock.patch("muse.cli.commands.hub._get_hub_and_identity", return_value=("http://localhost:10003", mock.MagicMock())): args = argparse.Namespace(target=repo_id, yes=True, hub=None, json_output=True) captured = io.StringIO() with mock.patch("sys.stdout", captured): run_repo_delete(args) output = json_mod.loads(captured.getvalue()) assert output["deleted"] is True assert output["repo_id"] == repo_id