gabriel / muse public
test_hub_list_envelopes.py python
388 lines 15.2 KB
Raw
sha256:51116ec824246acde6abf729e6ba854c223dc5173eff31a645520208023b0652 refactor(bridge): comprehensive spec sweep — close all issu… Sonnet 4.6 minor ⚠ breaking 28 days ago
1 """TDD tests for hub list envelope consistency.
2
3 All ``muse hub <noun> list --json`` commands must return a JSON **object**
4 (``{}``) with a top-level key naming the collection, ``total``, and
5 (where applicable) ``next_cursor``. Returning a bare array (``[]``) is an
6 agent-ergonomics bug — agents cannot tell the total count or advance pagination
7 from a bare array.
8
9 Commands under test and their required envelope shapes:
10
11 muse hub issue list --json
12 → {"issues": [...], "total": N, "next_cursor": str|null}
13
14 muse hub proposal list --json
15 → {"proposals": [...], "total": N, "next_cursor": str|null}
16
17 muse hub label list --json
18 → {"labels": [...], "total": N}
19
20 The ``muse hub repo list --json`` command already returns the correct envelope
21 and is included here as a non-regression baseline.
22
23 All network calls are mocked — no real HTTP traffic occurs.
24 """
25
26 from __future__ import annotations
27 from collections.abc import Mapping
28
29 import json
30 import pathlib
31 import unittest.mock
32 from unittest.mock import MagicMock, patch
33
34 import pytest
35 from tests.cli_test_helper import CliRunner, InvokeResult
36 from muse.core.paths import muse_dir
37
38 cli = None
39 runner = CliRunner()
40
41 _HUB = "http://localhost:19991/gabriel/muse"
42
43
44 # ---------------------------------------------------------------------------
45 # Fixture & helpers
46 # ---------------------------------------------------------------------------
47
48
49 @pytest.fixture
50 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
51 from muse._version import __version__
52
53 dot_muse = muse_dir(tmp_path)
54 for sub in ("refs/heads", "objects", "commits", "snapshots"):
55 (dot_muse / sub).mkdir(parents=True, exist_ok=True)
56 (dot_muse / "repo.json").write_text(
57 json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"})
58 )
59 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
60 (dot_muse / "refs" / "heads" / "main").write_text("")
61 (dot_muse / "config.toml").write_text("")
62 muse_home = tmp_path / ".muse-home"
63 muse_home.mkdir()
64 (muse_home / "identity.toml").write_text("")
65 monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", muse_home / "identity.toml")
66 monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", muse_home)
67 monkeypatch.chdir(tmp_path)
68 return tmp_path
69
70
71 def _make_identity() -> "SigningIdentity":
72 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
73 from muse.core.transport import SigningIdentity
74 return SigningIdentity(handle="testuser", private_key=Ed25519PrivateKey.generate())
75
76
77 def _store_identity_for(hub_url: str, repo: pathlib.Path) -> None:
78 from muse.core.identity import IdentityEntry, save_identity
79 from muse.core.hdkeys import muse_path, DOMAIN_IDENTITY, ENTITY_HUMAN, ROLE_SIGN
80
81 _TEST_MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
82 hd_path = muse_path(DOMAIN_IDENTITY, ENTITY_HUMAN, 0)
83 entry: IdentityEntry = {
84 "type": "human",
85 "handle": "testuser",
86 "algorithm": "ed25519",
87 "fingerprint": "test-fp-testuser",
88 "hd_path": hd_path,
89 }
90 save_identity(hub_url, entry, mnemonic=_TEST_MNEMONIC)
91
92
93 def _setup(repo: pathlib.Path) -> None:
94 runner.invoke(cli, ["hub", "connect", _HUB])
95 _store_identity_for(_HUB, repo)
96
97
98 def _api_mock(*payloads: bytes) -> list[MagicMock]:
99 mocks = []
100 for p in payloads:
101 m = MagicMock()
102 m.__enter__ = lambda s: s
103 m.__exit__ = MagicMock(return_value=False)
104 m.read.return_value = p
105 mocks.append(m)
106 return mocks
107
108
109 def _first_json_object(result: InvokeResult) -> Mapping[str, object]:
110 """Extract the first ``{...}`` JSON object from stdout."""
111 for line in result.output.splitlines():
112 stripped = line.strip()
113 if stripped.startswith("{"):
114 return json.loads(stripped)
115 raise ValueError(f"No JSON object in output:\n{result.output!r}")
116
117
118 _REPO_REF = json.dumps({"repo_id": "repo-id"}).encode()
119
120
121 # ---------------------------------------------------------------------------
122 # hub issue list
123 # ---------------------------------------------------------------------------
124
125
126 class TestIssueListEnvelope:
127 """``muse hub issue list --json`` must return a wrapped object, not a bare list."""
128
129 _ISSUE = {
130 "number": 1,
131 "title": "Bug report",
132 "state": "open",
133 "author": "alice",
134 "body": "",
135 "labels": [],
136 "assignees": [],
137 "createdAt": "2026-01-01T00:00:00Z",
138 "updatedAt": "2026-01-01T00:00:00Z",
139 }
140
141 def test_json_is_object_not_array(self, repo: pathlib.Path) -> None:
142 _setup(repo)
143 api_resp = json.dumps(
144 {"issues": [self._ISSUE], "total": 1, "nextCursor": None}
145 ).encode()
146 with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)):
147 result = runner.invoke(cli, ["hub", "issue", "list", "--json"])
148 assert result.exit_code == 0
149 data = _first_json_object(result)
150 assert isinstance(data, dict), "Expected a JSON object, got a bare list"
151
152 def test_json_has_issues_key(self, repo: pathlib.Path) -> None:
153 _setup(repo)
154 api_resp = json.dumps(
155 {"issues": [self._ISSUE], "total": 1, "nextCursor": None}
156 ).encode()
157 with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)):
158 result = runner.invoke(cli, ["hub", "issue", "list", "--json"])
159 data = _first_json_object(result)
160 assert "issues" in data
161
162 def test_json_has_total_key(self, repo: pathlib.Path) -> None:
163 _setup(repo)
164 api_resp = json.dumps(
165 {"issues": [self._ISSUE], "total": 7, "nextCursor": None}
166 ).encode()
167 with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)):
168 result = runner.invoke(cli, ["hub", "issue", "list", "--json"])
169 data = _first_json_object(result)
170 assert data["total"] == 7
171
172 def test_json_has_next_cursor_key(self, repo: pathlib.Path) -> None:
173 _setup(repo)
174 api_resp = json.dumps(
175 {"issues": [self._ISSUE], "total": 1, "nextCursor": None}
176 ).encode()
177 with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)):
178 result = runner.invoke(cli, ["hub", "issue", "list", "--json"])
179 data = _first_json_object(result)
180 assert "next_cursor" in data
181
182 def test_issues_value_is_list(self, repo: pathlib.Path) -> None:
183 _setup(repo)
184 api_resp = json.dumps(
185 {"issues": [self._ISSUE], "total": 1, "nextCursor": None}
186 ).encode()
187 with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)):
188 result = runner.invoke(cli, ["hub", "issue", "list", "--json"])
189 data = _first_json_object(result)
190 assert isinstance(data["issues"], list)
191
192 def test_empty_list_still_wrapped(self, repo: pathlib.Path) -> None:
193 _setup(repo)
194 api_resp = json.dumps({"issues": [], "total": 0, "nextCursor": None}).encode()
195 with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)):
196 result = runner.invoke(cli, ["hub", "issue", "list", "--json"])
197 assert result.exit_code == 0
198 data = _first_json_object(result)
199 assert data["issues"] == []
200 assert data["total"] == 0
201
202 def test_next_cursor_propagated(self, repo: pathlib.Path) -> None:
203 _setup(repo)
204 api_resp = json.dumps(
205 {"issues": [self._ISSUE], "total": 50, "nextCursor": "42"}
206 ).encode()
207 with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)):
208 result = runner.invoke(cli, ["hub", "issue", "list", "--json"])
209 data = _first_json_object(result)
210 assert data["next_cursor"] == "42"
211
212
213 # ---------------------------------------------------------------------------
214 # hub proposal list
215 # ---------------------------------------------------------------------------
216
217
218 class TestProposalListEnvelope:
219 """``muse hub proposal list --json`` must return a wrapped object, not a bare list."""
220
221 _PROPOSAL = {
222 "proposalId": "abc12345-0000-0000-0000-000000000001",
223 "title": "Add feature X",
224 "state": "open",
225 "fromBranch": "feat/x",
226 "toBranch": "dev",
227 "author": "alice",
228 "createdAt": "2026-01-01T00:00:00Z",
229 }
230
231 def test_json_is_object_not_array(self, repo: pathlib.Path) -> None:
232 _setup(repo)
233 api_resp = json.dumps(
234 {"proposals": [self._PROPOSAL], "total": 1, "nextCursor": None}
235 ).encode()
236 with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)):
237 result = runner.invoke(cli, ["hub", "proposal", "list", "--json"])
238 assert result.exit_code == 0
239 data = _first_json_object(result)
240 assert isinstance(data, dict), "Expected a JSON object, got a bare list"
241
242 def test_json_has_proposals_key(self, repo: pathlib.Path) -> None:
243 _setup(repo)
244 api_resp = json.dumps(
245 {"proposals": [self._PROPOSAL], "total": 1, "nextCursor": None}
246 ).encode()
247 with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)):
248 result = runner.invoke(cli, ["hub", "proposal", "list", "--json"])
249 data = _first_json_object(result)
250 assert "proposals" in data
251
252 def test_json_has_total_key(self, repo: pathlib.Path) -> None:
253 _setup(repo)
254 api_resp = json.dumps(
255 {"proposals": [self._PROPOSAL], "total": 3, "nextCursor": None}
256 ).encode()
257 with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)):
258 result = runner.invoke(cli, ["hub", "proposal", "list", "--json"])
259 data = _first_json_object(result)
260 assert data["total"] == 3
261
262 def test_json_has_next_cursor_key(self, repo: pathlib.Path) -> None:
263 _setup(repo)
264 api_resp = json.dumps(
265 {"proposals": [self._PROPOSAL], "total": 1, "nextCursor": None}
266 ).encode()
267 with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)):
268 result = runner.invoke(cli, ["hub", "proposal", "list", "--json"])
269 data = _first_json_object(result)
270 assert "next_cursor" in data
271
272 def test_empty_list_still_wrapped(self, repo: pathlib.Path) -> None:
273 _setup(repo)
274 api_resp = json.dumps({"proposals": [], "total": 0, "nextCursor": None}).encode()
275 with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)):
276 result = runner.invoke(cli, ["hub", "proposal", "list", "--json"])
277 assert result.exit_code == 0
278 data = _first_json_object(result)
279 assert data["proposals"] == []
280 assert data["total"] == 0
281
282 def test_next_cursor_propagated(self, repo: pathlib.Path) -> None:
283 _setup(repo)
284 api_resp = json.dumps(
285 {"proposals": [self._PROPOSAL], "total": 100, "nextCursor": "99"}
286 ).encode()
287 with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)):
288 result = runner.invoke(cli, ["hub", "proposal", "list", "--json"])
289 data = _first_json_object(result)
290 assert data["next_cursor"] == "99"
291
292
293 # ---------------------------------------------------------------------------
294 # hub label list
295 # ---------------------------------------------------------------------------
296
297
298 class TestLabelListEnvelope:
299 """``muse hub label list --json`` must return a wrapped object, not a bare list."""
300
301 _LABEL = {
302 "labelId": "lbl-id-001",
303 "repoId": "repo-id",
304 "name": "bug",
305 "color": "#d73a4a",
306 "description": "Something isn't working",
307 }
308
309 def test_json_is_object_not_array(self, repo: pathlib.Path) -> None:
310 _setup(repo)
311 api_resp = json.dumps({"items": [self._LABEL], "total": 1}).encode()
312 with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)):
313 result = runner.invoke(cli, ["hub", "label", "list", "--json"])
314 assert result.exit_code == 0
315 data = _first_json_object(result)
316 assert isinstance(data, dict), "Expected a JSON object, got a bare list"
317
318 def test_json_has_labels_key(self, repo: pathlib.Path) -> None:
319 _setup(repo)
320 api_resp = json.dumps({"items": [self._LABEL], "total": 1}).encode()
321 with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)):
322 result = runner.invoke(cli, ["hub", "label", "list", "--json"])
323 data = _first_json_object(result)
324 assert "labels" in data
325
326 def test_json_has_total_key(self, repo: pathlib.Path) -> None:
327 _setup(repo)
328 api_resp = json.dumps({"items": [self._LABEL, self._LABEL], "total": 2}).encode()
329 with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)):
330 result = runner.invoke(cli, ["hub", "label", "list", "--json"])
331 data = _first_json_object(result)
332 assert data["total"] == 2
333
334 def test_labels_value_is_list(self, repo: pathlib.Path) -> None:
335 _setup(repo)
336 api_resp = json.dumps({"items": [self._LABEL], "total": 1}).encode()
337 with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)):
338 result = runner.invoke(cli, ["hub", "label", "list", "--json"])
339 data = _first_json_object(result)
340 assert isinstance(data["labels"], list)
341
342 def test_empty_list_still_wrapped(self, repo: pathlib.Path) -> None:
343 _setup(repo)
344 api_resp = json.dumps({"items": [], "total": 0}).encode()
345 with patch("urllib.request.urlopen", side_effect=_api_mock(_REPO_REF, api_resp)):
346 result = runner.invoke(cli, ["hub", "label", "list", "--json"])
347 assert result.exit_code == 0
348 data = _first_json_object(result)
349 assert data["labels"] == []
350 assert data["total"] == 0
351
352
353 # ---------------------------------------------------------------------------
354 # hub repo list — baseline (already correct, must not regress)
355 # ---------------------------------------------------------------------------
356
357
358 class TestRepoListEnvelopeBaseline:
359 """``muse hub repo list --json`` already returns the correct envelope.
360
361 Included as a regression guard so any future refactor that breaks the
362 working command gets caught immediately.
363 """
364
365 _REPO = {
366 "repoId": "repo-id",
367 "name": "muse",
368 "owner": "gabriel",
369 "slug": "gabriel/muse",
370 "visibility": "public",
371 "description": "",
372 "tags": [],
373 "defaultBranch": "main",
374 "createdAt": "2026-01-01T00:00:00Z",
375 "pushedAt": "2026-01-01T00:00:00Z",
376 }
377
378 def test_json_is_object_not_array(self, repo: pathlib.Path) -> None:
379 _setup(repo)
380 api_resp = json.dumps({"repos": [self._REPO], "total": 1, "nextCursor": None}).encode()
381 with patch("urllib.request.urlopen", side_effect=_api_mock(api_resp)):
382 result = runner.invoke(cli, ["hub", "repo", "list", "--json"])
383 assert result.exit_code == 0
384 data = _first_json_object(result)
385 assert isinstance(data, dict)
386 assert "repos" in data
387 assert "total" in data
388 assert "next_cursor" in data
File History 1 commit
sha256:51116ec824246acde6abf729e6ba854c223dc5173eff31a645520208023b0652 refactor(bridge): comprehensive spec sweep — close all issu… Sonnet 4.6 minor 28 days ago