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