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