"""TDD tests for hub list envelope consistency. All ``muse hub list --json`` commands must return a JSON **object** (``{}``) with a top-level key naming the collection, ``total``, and (where applicable) ``next_cursor``. Returning a bare array (``[]``) is an agent-ergonomics bug — agents cannot tell the total count or advance pagination from a bare array. Commands under test and their required envelope shapes: muse hub issue list --json → {"issues": [...], "total": N, "next_cursor": str|null} muse hub proposal list --json → {"proposals": [...], "total": N, "next_cursor": str|null} muse hub label list --json → {"labels": [...], "total": N} The ``muse hub repo list --json`` command already returns the correct envelope and is included here as a non-regression baseline. All network calls are mocked — no real HTTP traffic occurs. """ from __future__ import annotations from collections.abc import Mapping import json import pathlib import unittest.mock from unittest.mock import MagicMock, patch import pytest from tests.cli_test_helper import CliRunner, InvokeResult from muse.core.paths import muse_dir cli = None runner = CliRunner() _HUB = "http://localhost:19991/gabriel/muse" # --------------------------------------------------------------------------- # Fixture & helpers # --------------------------------------------------------------------------- @pytest.fixture def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: from muse._version import __version__ dot_muse = muse_dir(tmp_path) for sub in ("refs/heads", "objects", "commits", "snapshots"): (dot_muse / sub).mkdir(parents=True, exist_ok=True) (dot_muse / "repo.json").write_text( json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"}) ) (dot_muse / "HEAD").write_text("ref: refs/heads/main\n") (dot_muse / "refs" / "heads" / "main").write_text("") (dot_muse / "config.toml").write_text("") muse_home = tmp_path / ".muse-home" muse_home.mkdir() (muse_home / "identity.toml").write_text("") monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", muse_home / "identity.toml") monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", muse_home) monkeypatch.chdir(tmp_path) return tmp_path def _make_identity() -> "SigningIdentity": from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from muse.core.transport import SigningIdentity return SigningIdentity(handle="testuser", private_key=Ed25519PrivateKey.generate()) def _store_identity_for(hub_url: str, repo: pathlib.Path) -> None: from muse.core.identity import IdentityEntry, save_identity from muse.core.hdkeys import muse_path, DOMAIN_IDENTITY, ENTITY_HUMAN, ROLE_SIGN _TEST_MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" hd_path = muse_path(DOMAIN_IDENTITY, ENTITY_HUMAN, 0) entry: IdentityEntry = { "type": "human", "handle": "testuser", "algorithm": "ed25519", "fingerprint": "test-fp-testuser", "hd_path": hd_path, } save_identity(hub_url, entry, mnemonic=_TEST_MNEMONIC) def _setup(repo: pathlib.Path) -> None: runner.invoke(cli, ["hub", "connect", _HUB]) _store_identity_for(_HUB, repo) def _api_mock(*payloads: bytes) -> list[MagicMock]: mocks = [] for p in payloads: m = MagicMock() m.__enter__ = lambda s: s m.__exit__ = MagicMock(return_value=False) m.read.return_value = p mocks.append(m) return mocks def _first_json_object(result: InvokeResult) -> Mapping[str, object]: """Extract the first ``{...}`` JSON object from stdout.""" for line in result.output.splitlines(): stripped = line.strip() if stripped.startswith("{"): return json.loads(stripped) raise ValueError(f"No JSON object in output:\n{result.output!r}") _REPO_REF = json.dumps({"repo_id": "repo-id"}).encode() # --------------------------------------------------------------------------- # hub issue list # --------------------------------------------------------------------------- class TestIssueListEnvelope: """``muse hub issue list --json`` must return a wrapped object, not a bare list.""" _ISSUE = { "number": 1, "title": "Bug report", "state": "open", "author": "alice", "body": "", "labels": [], "assignees": [], "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z", } def test_json_is_object_not_array(self, repo: pathlib.Path) -> None: _setup(repo) api_resp = json.dumps( {"issues": [self._ISSUE], "total": 1, "nextCursor": None} ).encode() with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)): result = runner.invoke(cli, ["hub", "issue", "list", "--json"]) assert result.exit_code == 0 data = _first_json_object(result) assert isinstance(data, dict), "Expected a JSON object, got a bare list" def test_json_has_issues_key(self, repo: pathlib.Path) -> None: _setup(repo) api_resp = json.dumps( {"issues": [self._ISSUE], "total": 1, "nextCursor": None} ).encode() with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)): result = runner.invoke(cli, ["hub", "issue", "list", "--json"]) data = _first_json_object(result) assert "issues" in data def test_json_has_total_key(self, repo: pathlib.Path) -> None: _setup(repo) api_resp = json.dumps( {"issues": [self._ISSUE], "total": 7, "nextCursor": None} ).encode() with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)): result = runner.invoke(cli, ["hub", "issue", "list", "--json"]) data = _first_json_object(result) assert data["total"] == 7 def test_json_has_next_cursor_key(self, repo: pathlib.Path) -> None: _setup(repo) api_resp = json.dumps( {"issues": [self._ISSUE], "total": 1, "nextCursor": None} ).encode() with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)): result = runner.invoke(cli, ["hub", "issue", "list", "--json"]) data = _first_json_object(result) assert "next_cursor" in data def test_issues_value_is_list(self, repo: pathlib.Path) -> None: _setup(repo) api_resp = json.dumps( {"issues": [self._ISSUE], "total": 1, "nextCursor": None} ).encode() with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)): result = runner.invoke(cli, ["hub", "issue", "list", "--json"]) data = _first_json_object(result) assert isinstance(data["issues"], list) def test_empty_list_still_wrapped(self, repo: pathlib.Path) -> None: _setup(repo) api_resp = json.dumps({"issues": [], "total": 0, "nextCursor": None}).encode() with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)): result = runner.invoke(cli, ["hub", "issue", "list", "--json"]) assert result.exit_code == 0 data = _first_json_object(result) assert data["issues"] == [] assert data["total"] == 0 def test_next_cursor_propagated(self, repo: pathlib.Path) -> None: _setup(repo) api_resp = json.dumps( {"issues": [self._ISSUE], "total": 50, "nextCursor": "42"} ).encode() with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)): result = runner.invoke(cli, ["hub", "issue", "list", "--json"]) data = _first_json_object(result) assert data["next_cursor"] == "42" # --------------------------------------------------------------------------- # hub proposal list # --------------------------------------------------------------------------- class TestProposalListEnvelope: """``muse hub proposal list --json`` must return a wrapped object, not a bare list.""" _PROPOSAL = { "proposalId": "abc12345-0000-0000-0000-000000000001", "title": "Add feature X", "state": "open", "fromBranch": "feat/x", "toBranch": "dev", "author": "alice", "createdAt": "2026-01-01T00:00:00Z", } def test_json_is_object_not_array(self, repo: pathlib.Path) -> None: _setup(repo) api_resp = json.dumps( {"proposals": [self._PROPOSAL], "total": 1, "nextCursor": None} ).encode() with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)): result = runner.invoke(cli, ["hub", "proposal", "list", "--json"]) assert result.exit_code == 0 data = _first_json_object(result) assert isinstance(data, dict), "Expected a JSON object, got a bare list" def test_json_has_proposals_key(self, repo: pathlib.Path) -> None: _setup(repo) api_resp = json.dumps( {"proposals": [self._PROPOSAL], "total": 1, "nextCursor": None} ).encode() with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)): result = runner.invoke(cli, ["hub", "proposal", "list", "--json"]) data = _first_json_object(result) assert "proposals" in data def test_json_has_total_key(self, repo: pathlib.Path) -> None: _setup(repo) api_resp = json.dumps( {"proposals": [self._PROPOSAL], "total": 3, "nextCursor": None} ).encode() with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)): result = runner.invoke(cli, ["hub", "proposal", "list", "--json"]) data = _first_json_object(result) assert data["total"] == 3 def test_json_has_next_cursor_key(self, repo: pathlib.Path) -> None: _setup(repo) api_resp = json.dumps( {"proposals": [self._PROPOSAL], "total": 1, "nextCursor": None} ).encode() with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)): result = runner.invoke(cli, ["hub", "proposal", "list", "--json"]) data = _first_json_object(result) assert "next_cursor" in data def test_empty_list_still_wrapped(self, repo: pathlib.Path) -> None: _setup(repo) api_resp = json.dumps({"proposals": [], "total": 0, "nextCursor": None}).encode() with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)): result = runner.invoke(cli, ["hub", "proposal", "list", "--json"]) assert result.exit_code == 0 data = _first_json_object(result) assert data["proposals"] == [] assert data["total"] == 0 def test_next_cursor_propagated(self, repo: pathlib.Path) -> None: _setup(repo) api_resp = json.dumps( {"proposals": [self._PROPOSAL], "total": 100, "nextCursor": "99"} ).encode() with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)): result = runner.invoke(cli, ["hub", "proposal", "list", "--json"]) data = _first_json_object(result) assert data["next_cursor"] == "99" # --------------------------------------------------------------------------- # hub label list # --------------------------------------------------------------------------- class TestLabelListEnvelope: """``muse hub label list --json`` must return a wrapped object, not a bare list.""" _LABEL = { "labelId": "lbl-id-001", "repoId": "repo-id", "name": "bug", "color": "#d73a4a", "description": "Something isn't working", } def test_json_is_object_not_array(self, repo: pathlib.Path) -> None: _setup(repo) api_resp = json.dumps({"items": [self._LABEL], "total": 1}).encode() with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)): result = runner.invoke(cli, ["hub", "label", "list", "--json"]) assert result.exit_code == 0 data = _first_json_object(result) assert isinstance(data, dict), "Expected a JSON object, got a bare list" def test_json_has_labels_key(self, repo: pathlib.Path) -> None: _setup(repo) api_resp = json.dumps({"items": [self._LABEL], "total": 1}).encode() with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)): result = runner.invoke(cli, ["hub", "label", "list", "--json"]) data = _first_json_object(result) assert "labels" in data def test_json_has_total_key(self, repo: pathlib.Path) -> None: _setup(repo) api_resp = json.dumps({"items": [self._LABEL, self._LABEL], "total": 2}).encode() with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)): result = runner.invoke(cli, ["hub", "label", "list", "--json"]) data = _first_json_object(result) assert data["total"] == 2 def test_labels_value_is_list(self, repo: pathlib.Path) -> None: _setup(repo) api_resp = json.dumps({"items": [self._LABEL], "total": 1}).encode() with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)): result = runner.invoke(cli, ["hub", "label", "list", "--json"]) data = _first_json_object(result) assert isinstance(data["labels"], list) def test_empty_list_still_wrapped(self, repo: pathlib.Path) -> None: _setup(repo) api_resp = json.dumps({"items": [], "total": 0}).encode() with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)): result = runner.invoke(cli, ["hub", "label", "list", "--json"]) assert result.exit_code == 0 data = _first_json_object(result) assert data["labels"] == [] assert data["total"] == 0 # --------------------------------------------------------------------------- # hub repo list — baseline (already correct, must not regress) # --------------------------------------------------------------------------- class TestRepoListEnvelopeBaseline: """``muse hub repo list --json`` already returns the correct envelope. Included as a regression guard so any future refactor that breaks the working command gets caught immediately. """ _REPO = { "repoId": "repo-id", "name": "muse", "owner": "gabriel", "slug": "gabriel/muse", "visibility": "public", "description": "", "tags": [], "defaultBranch": "main", "createdAt": "2026-01-01T00:00:00Z", "pushedAt": "2026-01-01T00:00:00Z", } def test_json_is_object_not_array(self, repo: pathlib.Path) -> None: _setup(repo) api_resp = json.dumps({"repos": [self._REPO], "total": 1, "nextCursor": None}).encode() with patch("urllib.request.urlopen", side_effect=_api_mock(api_resp)): result = runner.invoke(cli, ["hub", "repo", "list", "--json"]) assert result.exit_code == 0 data = _first_json_object(result) assert isinstance(data, dict) assert "repos" in data assert "total" in data assert "next_cursor" in data