gabriel / muse public
test_cli_hub.py python
970 lines 41.7 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Tests for `muse hub` CLI commands — connect, status, disconnect, ping.
2
3 All network calls are mocked — no real HTTP traffic occurs. The identity
4 store is isolated per test using a tmp_path override.
5 """
6
7 from __future__ import annotations
8
9 import io
10 import json
11 import pathlib
12 import unittest.mock
13 import urllib.error
14 import urllib.request
15 import urllib.response
16
17 import pytest
18 from tests.cli_test_helper import CliRunner
19
20 from muse._version import __version__
21 cli = None # argparse migration — CliRunner ignores this arg
22 from muse.cli.commands.hub import _hub_hostname, _normalise_url, _ping_hub
23 from muse.cli.config import get_hub_url, set_hub_url
24 from muse.core.identity import IdentityEntry, save_identity
25
26 runner = CliRunner()
27
28
29 # ---------------------------------------------------------------------------
30 # Fixture: minimal Muse repo
31 # ---------------------------------------------------------------------------
32
33
34 @pytest.fixture()
35 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
36 """Minimal .muse/ repo; hub tests don't need commits."""
37 muse_dir = tmp_path / ".muse"
38 (muse_dir / "refs" / "heads").mkdir(parents=True)
39 (muse_dir / "objects").mkdir()
40 (muse_dir / "commits").mkdir()
41 (muse_dir / "snapshots").mkdir()
42 (muse_dir / "repo.json").write_text(
43 json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "midi"})
44 )
45 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
46 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
47 monkeypatch.chdir(tmp_path)
48 # Redirect the identity store to tmp_path so tests never touch ~/.muse/
49 fake_identity_dir = tmp_path / "fake_home" / ".muse"
50 fake_identity_dir.mkdir(parents=True)
51 fake_identity_file = fake_identity_dir / "identity.toml"
52 monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_identity_dir)
53 monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", fake_identity_file)
54 return tmp_path
55
56
57 # ---------------------------------------------------------------------------
58 # Unit tests for pure helper functions
59 # ---------------------------------------------------------------------------
60
61
62 class TestNormaliseUrl:
63 def test_bare_hostname_gets_https(self) -> None:
64 assert _normalise_url("musehub.ai") == "https://musehub.ai"
65
66 def test_https_url_unchanged(self) -> None:
67 assert _normalise_url("https://musehub.ai") == "https://musehub.ai"
68
69 def test_trailing_slash_stripped(self) -> None:
70 assert _normalise_url("https://musehub.ai/") == "https://musehub.ai"
71
72 def test_http_url_raises(self) -> None:
73 with pytest.raises(ValueError, match="Insecure"):
74 _normalise_url("http://musehub.ai")
75
76 def test_http_suggests_https(self) -> None:
77 with pytest.raises(ValueError, match="https://"):
78 _normalise_url("http://musehub.ai")
79
80 def test_whitespace_stripped(self) -> None:
81 assert _normalise_url(" https://musehub.ai ") == "https://musehub.ai"
82
83
84 class TestHubHostname:
85 def test_extracts_hostname_from_https_url(self) -> None:
86 assert _hub_hostname("https://musehub.ai/repos/r1") == "musehub.ai"
87
88 def test_bare_hostname(self) -> None:
89 assert _hub_hostname("musehub.ai") == "musehub.ai"
90
91 def test_strips_path(self) -> None:
92 assert _hub_hostname("https://musehub.ai/deep/path") == "musehub.ai"
93
94 def test_preserves_port(self) -> None:
95 assert _hub_hostname("https://musehub.ai:8443") == "musehub.ai:8443"
96
97
98 class TestPingHub:
99 def test_2xx_returns_true(self) -> None:
100 mock_resp = unittest.mock.MagicMock()
101 mock_resp.status = 200
102 mock_resp.__enter__ = lambda s: s
103 mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False)
104 with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", return_value=mock_resp):
105 ok, msg = _ping_hub("https://musehub.ai")
106 assert ok is True
107 assert "200" in msg
108
109 def test_5xx_returns_false(self) -> None:
110 mock_resp = unittest.mock.MagicMock()
111 mock_resp.status = 503
112 mock_resp.__enter__ = lambda s: s
113 mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False)
114 with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", return_value=mock_resp):
115 ok, msg = _ping_hub("https://musehub.ai")
116 assert ok is False
117
118 def test_http_error_returns_false(self) -> None:
119 err = urllib.error.HTTPError("https://musehub.ai/health", 401, "Unauthorized", {}, io.BytesIO(b"Unauthorized"))
120 with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", side_effect=err):
121 ok, msg = _ping_hub("https://musehub.ai")
122 assert ok is False
123 assert "401" in msg
124
125 def test_url_error_returns_false(self) -> None:
126 err = urllib.error.URLError("name resolution failure")
127 with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", side_effect=err):
128 ok, msg = _ping_hub("https://musehub.ai")
129 assert ok is False
130
131 def test_timeout_error_returns_false(self) -> None:
132 with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", side_effect=TimeoutError()):
133 ok, msg = _ping_hub("https://musehub.ai")
134 assert ok is False
135 assert "timed out" in msg
136
137 def test_os_error_returns_false(self) -> None:
138 with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", side_effect=OSError("network down")):
139 ok, msg = _ping_hub("https://musehub.ai")
140 assert ok is False
141
142 def test_health_endpoint_used(self) -> None:
143 calls: list[str] = []
144
145 def _fake_open(req: urllib.request.Request, timeout: int = 0) -> urllib.response.addinfourl:
146 calls.append(req.full_url)
147 raise urllib.error.URLError("stop")
148
149 with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", side_effect=_fake_open):
150 _ping_hub("https://musehub.ai")
151 assert calls and calls[0] == "https://musehub.ai/health"
152
153
154 # ---------------------------------------------------------------------------
155 # hub connect
156 # ---------------------------------------------------------------------------
157
158
159 class TestHubConnect:
160 def test_connect_bare_hostname(self, repo: pathlib.Path) -> None:
161 result = runner.invoke(cli, ["hub", "connect", "musehub.ai"])
162 assert result.exit_code == 0
163 assert "Connected" in result.output
164
165 def test_connect_stores_https_url(self, repo: pathlib.Path) -> None:
166 runner.invoke(cli, ["hub", "connect", "musehub.ai"])
167 stored = get_hub_url(repo)
168 assert stored == "https://musehub.ai"
169
170 def test_connect_https_url_directly(self, repo: pathlib.Path) -> None:
171 result = runner.invoke(cli, ["hub", "connect", "https://musehub.ai"])
172 assert result.exit_code == 0
173 assert get_hub_url(repo) == "https://musehub.ai"
174
175 def test_connect_http_rejected(self, repo: pathlib.Path) -> None:
176 result = runner.invoke(cli, ["hub", "connect", "http://musehub.ai"])
177 assert result.exit_code != 0
178 assert "Insecure" in result.output or "rejected" in result.output
179
180 def test_connect_warns_on_hub_switch(self, repo: pathlib.Path) -> None:
181 runner.invoke(cli, ["hub", "connect", "https://hub1.example.com"])
182 result = runner.invoke(cli, ["hub", "connect", "https://hub2.example.com"])
183 assert result.exit_code == 0
184 assert "hub1.example.com" in result.output or "Switching" in result.output
185
186 def test_connect_shows_identity_if_already_logged_in(self, repo: pathlib.Path) -> None:
187 entry: IdentityEntry = {"type": "human", "handle": "Alice", "key_path": "/k"}
188 save_identity("https://musehub.ai", entry)
189 result = runner.invoke(cli, ["hub", "connect", "https://musehub.ai"])
190 assert result.exit_code == 0
191 assert "Alice" in result.output or "human" in result.output
192
193 def test_connect_prompts_login_when_no_identity(self, repo: pathlib.Path) -> None:
194 result = runner.invoke(cli, ["hub", "connect", "https://musehub.ai"])
195 assert result.exit_code == 0
196 assert "muse auth" in result.output
197
198 def test_connect_fails_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
199 monkeypatch.chdir(tmp_path)
200 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
201 result = runner.invoke(cli, ["hub", "connect", "https://musehub.ai"])
202 assert result.exit_code != 0
203
204
205 # ---------------------------------------------------------------------------
206 # hub status
207 # ---------------------------------------------------------------------------
208
209
210 class TestHubStatus:
211 def _setup_hub(self, repo: pathlib.Path) -> None:
212 set_hub_url("https://musehub.ai", repo)
213
214 def test_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None:
215 result = runner.invoke(cli, ["hub", "status"])
216 assert result.exit_code != 0
217
218 def test_hub_url_shown(self, repo: pathlib.Path) -> None:
219 self._setup_hub(repo)
220 result = runner.invoke(cli, ["hub", "status"])
221 assert result.exit_code == 0
222 assert "musehub.ai" in result.output
223
224 def test_not_authenticated_shown(self, repo: pathlib.Path) -> None:
225 self._setup_hub(repo)
226 result = runner.invoke(cli, ["hub", "status"])
227 assert "not authenticated" in result.output or "auth" in result.output
228
229 def test_identity_fields_shown_when_logged_in(self, repo: pathlib.Path) -> None:
230 self._setup_hub(repo)
231 entry: IdentityEntry = {"type": "agent", "handle": "bot", "fingerprint": "agt_001", "key_path": "/k"}
232 save_identity("https://musehub.ai", entry)
233 result = runner.invoke(cli, ["hub", "status"])
234 assert "agent" in result.output
235 assert "bot" in result.output
236
237 def test_json_output_structure(self, repo: pathlib.Path) -> None:
238 self._setup_hub(repo)
239 result = runner.invoke(cli, ["hub", "status", "--json"])
240 assert result.exit_code == 0
241 data = json.loads(result.output)
242 assert "hub_url" in data
243 assert "hostname" in data
244 assert "authenticated" in data
245
246 def test_json_output_with_identity(self, repo: pathlib.Path) -> None:
247 self._setup_hub(repo)
248 entry: IdentityEntry = {"type": "human", "handle": "Alice", "fingerprint": "usr_1", "key_path": "/k"}
249 save_identity("https://musehub.ai", entry)
250 result = runner.invoke(cli, ["hub", "status", "--json"])
251 data = json.loads(result.output)
252 assert data["authenticated"] is True
253 assert data["identity_type"] == "human"
254 assert data["identity_name"] == "Alice"
255
256
257 # ---------------------------------------------------------------------------
258 # hub disconnect
259 # ---------------------------------------------------------------------------
260
261
262 class TestHubDisconnect:
263 def test_disconnect_clears_hub_url(self, repo: pathlib.Path) -> None:
264 set_hub_url("https://musehub.ai", repo)
265 result = runner.invoke(cli, ["hub", "disconnect"])
266 assert result.exit_code == 0
267 assert get_hub_url(repo) is None
268
269 def test_disconnect_shows_hostname(self, repo: pathlib.Path) -> None:
270 set_hub_url("https://musehub.ai", repo)
271 result = runner.invoke(cli, ["hub", "disconnect"])
272 assert "musehub.ai" in result.output
273
274 def test_disconnect_nothing_to_do(self, repo: pathlib.Path) -> None:
275 result = runner.invoke(cli, ["hub", "disconnect"])
276 assert result.exit_code == 0
277 assert "nothing" in result.output.lower() or "No hub" in result.output
278
279 def test_disconnect_preserves_identity(self, repo: pathlib.Path) -> None:
280 """Credentials in identity.toml must survive hub disconnect."""
281 set_hub_url("https://musehub.ai", repo)
282 entry: IdentityEntry = {"type": "human", "handle": "alice", "key_path": "/k"}
283 save_identity("https://musehub.ai", entry)
284 runner.invoke(cli, ["hub", "disconnect"])
285 from muse.core.identity import load_identity
286 assert load_identity("https://musehub.ai") is not None
287
288 def test_disconnect_fails_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
289 monkeypatch.chdir(tmp_path)
290 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
291 result = runner.invoke(cli, ["hub", "disconnect"])
292 assert result.exit_code != 0
293
294
295 # ---------------------------------------------------------------------------
296 # hub ping
297 # ---------------------------------------------------------------------------
298
299
300 class TestHubPing:
301 def _setup_hub(self, repo: pathlib.Path) -> None:
302 set_hub_url("https://musehub.ai", repo)
303
304 def test_ping_success(self, repo: pathlib.Path) -> None:
305 self._setup_hub(repo)
306 mock_resp = unittest.mock.MagicMock()
307 mock_resp.status = 200
308 mock_resp.__enter__ = lambda s: s
309 mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False)
310 with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", return_value=mock_resp):
311 result = runner.invoke(cli, ["hub", "ping"])
312 assert result.exit_code == 0
313 assert "200" in result.output or "OK" in result.output.upper()
314
315 def test_ping_failure_exits_nonzero(self, repo: pathlib.Path) -> None:
316 self._setup_hub(repo)
317 err = urllib.error.URLError("no route to host")
318 with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", side_effect=err):
319 result = runner.invoke(cli, ["hub", "ping"])
320 assert result.exit_code != 0
321
322 def test_ping_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None:
323 result = runner.invoke(cli, ["hub", "ping"])
324 assert result.exit_code != 0
325
326 def test_ping_fails_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
327 monkeypatch.chdir(tmp_path)
328 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
329 result = runner.invoke(cli, ["hub", "ping"])
330 assert result.exit_code != 0
331
332
333 # ---------------------------------------------------------------------------
334 # _enrich_hub_url_from_remote
335 # ---------------------------------------------------------------------------
336
337
338 class TestEnrichHubUrlFromRemote:
339 """_enrich_hub_url_from_remote appends owner/slug from a matching remote."""
340
341 def _write_remote(self, repo: pathlib.Path, name: str, url: str) -> None:
342 from muse.cli.config import set_remote
343 set_remote(name, url, repo)
344
345 def test_enriches_bare_hub_url_with_owner_slug(self, repo: pathlib.Path) -> None:
346 from muse.cli.commands.hub import _enrich_hub_url_from_remote
347 self._write_remote(repo, "local", "http://localhost:10003/gabriel/muse")
348 result = _enrich_hub_url_from_remote("http://localhost:10003")
349 assert result == "http://localhost:10003/gabriel/muse"
350
351 def test_leaves_already_enriched_url_unchanged(self, repo: pathlib.Path) -> None:
352 from muse.cli.commands.hub import _enrich_hub_url_from_remote
353 self._write_remote(repo, "local", "http://localhost:10003/gabriel/muse")
354 result = _enrich_hub_url_from_remote("http://localhost:10003/gabriel/muse")
355 assert result == "http://localhost:10003/gabriel/muse"
356
357 def test_returns_bare_url_when_no_matching_remote(self, repo: pathlib.Path) -> None:
358 from muse.cli.commands.hub import _enrich_hub_url_from_remote
359 # remote is on a different host — no match
360 self._write_remote(repo, "staging", "http://otherhost:10003/gabriel/muse")
361 result = _enrich_hub_url_from_remote("http://localhost:10003")
362 assert result == "http://localhost:10003"
363
364 def test_returns_bare_url_when_no_remotes_configured(self, repo: pathlib.Path) -> None:
365 from muse.cli.commands.hub import _enrich_hub_url_from_remote
366 result = _enrich_hub_url_from_remote("http://localhost:10003")
367 assert result == "http://localhost:10003"
368
369 def test_returns_bare_url_outside_repo(
370 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
371 ) -> None:
372 monkeypatch.chdir(tmp_path)
373 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
374 from muse.cli.commands.hub import _enrich_hub_url_from_remote
375 result = _enrich_hub_url_from_remote("http://localhost:10003")
376 assert result == "http://localhost:10003"
377
378
379 # ---------------------------------------------------------------------------
380 # muse hub issue read (renamed from "get")
381 # ---------------------------------------------------------------------------
382
383
384 class TestHubIssueReadCommand:
385 """'muse hub issue read' is the canonical verb — 'get' is gone."""
386
387 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
388 set_hub_url(hub_url, repo)
389 identity = IdentityEntry(
390 type="human",
391 handle="gabriel",
392 key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"),
393 algorithm="ed25519",
394 fingerprint="deadbeef",
395 )
396 save_identity(hub_url, identity)
397
398 def test_issue_read_subcommand_exists(self, repo: pathlib.Path) -> None:
399 """'read' must be a valid subcommand — exit code must not be 2 (unknown subcommand)."""
400 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
401 mock_resp = {"number": 1, "title": "Test", "state": "open", "body": ""}
402 with unittest.mock.patch(
403 "muse.cli.commands.hub._hub_api", return_value=mock_resp
404 ):
405 result = runner.invoke(cli, ["hub", "issue", "read", "1"])
406 # A valid subcommand that fails for other reasons (auth, network) gives != 2
407 # The key assertion: exit_code 2 means "unrecognised subcommand" — that must not happen.
408 assert result.exit_code != 2, f"'read' is not a recognised subcommand: {result.output}"
409
410 def test_issue_get_subcommand_removed(self, repo: pathlib.Path) -> None:
411 """'get' must no longer be accepted."""
412 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
413 result = runner.invoke(cli, ["hub", "issue", "get", "1"])
414 assert result.exit_code != 0
415
416
417 # ---------------------------------------------------------------------------
418 # muse hub proposal read (renamed from "view")
419 # ---------------------------------------------------------------------------
420
421
422 class TestHubProposalReadCommand:
423 """'muse hub proposal read' is the canonical verb — 'view' is gone."""
424
425 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
426 set_hub_url(hub_url, repo)
427 identity = IdentityEntry(
428 type="human",
429 handle="gabriel",
430 key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"),
431 algorithm="ed25519",
432 fingerprint="deadbeef",
433 )
434 save_identity(hub_url, identity)
435
436 def test_proposal_read_subcommand_exists(self, repo: pathlib.Path) -> None:
437 """'read' must be a valid subcommand — exit code must not be 2."""
438 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
439 mock_proposal = {
440 "proposalId": "abc123",
441 "number": 1,
442 "title": "Test",
443 "state": "open",
444 "headBranch": "feat/x",
445 "baseBranch": "dev",
446 "author": "gabriel",
447 "createdAt": "2026-04-09T00:00:00Z",
448 "body": "",
449 }
450 with unittest.mock.patch(
451 "muse.cli.commands.hub._hub_api", return_value={"proposals": [mock_proposal]}
452 ):
453 result = runner.invoke(cli, ["hub", "proposal", "read", "abc123"])
454 assert result.exit_code != 2, f"'read' is not a recognised subcommand: {result.output}"
455
456 def test_proposal_view_subcommand_removed(self, repo: pathlib.Path) -> None:
457 """'view' must no longer be accepted."""
458 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
459 result = runner.invoke(cli, ["hub", "proposal", "view", "abc123"])
460 assert result.exit_code != 0
461
462
463 # ---------------------------------------------------------------------------
464 # muse hub issue update (renamed from "edit")
465 # ---------------------------------------------------------------------------
466
467
468 class TestHubIssueUpdateCommand:
469 """'muse hub issue update' is the canonical verb — 'edit' is gone."""
470
471 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
472 set_hub_url(hub_url, repo)
473 identity = IdentityEntry(
474 type="human",
475 handle="gabriel",
476 key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"),
477 algorithm="ed25519",
478 fingerprint="deadbeef",
479 )
480 save_identity(hub_url, identity)
481
482 def test_issue_update_subcommand_exists(self, repo: pathlib.Path) -> None:
483 """'update' must be a valid subcommand — exit code must not be 2 (unknown subcommand)."""
484 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
485 mock_resp = {"number": 1, "title": "Updated", "state": "open", "body": "new body"}
486 with unittest.mock.patch(
487 "muse.cli.commands.hub._hub_api", return_value=mock_resp
488 ):
489 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--body", "new body"])
490 assert result.exit_code != 2, f"'update' is not a recognised subcommand: {result.output}"
491
492 def test_issue_edit_subcommand_removed(self, repo: pathlib.Path) -> None:
493 """'edit' must no longer be accepted."""
494 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
495 result = runner.invoke(cli, ["hub", "issue", "edit", "1", "--body", "x"])
496 assert result.exit_code != 0
497
498
499 # ---------------------------------------------------------------------------
500 # muse hub repo update (renamed from "settings")
501 # ---------------------------------------------------------------------------
502
503
504 class TestHubRepoUpdateCommand:
505 """'muse hub repo update' is the canonical verb — 'settings' is gone."""
506
507 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
508 set_hub_url(hub_url, repo)
509 identity = IdentityEntry(
510 type="human",
511 handle="gabriel",
512 key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"),
513 algorithm="ed25519",
514 fingerprint="deadbeef",
515 )
516 save_identity(hub_url, identity)
517
518 def test_repo_update_subcommand_exists(self, repo: pathlib.Path) -> None:
519 """'update' must be a valid repo subcommand — exit code must not be 2."""
520 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
521 mock_resp = {"repoId": "abc", "name": "muse", "owner": "gabriel"}
522 with unittest.mock.patch(
523 "muse.cli.commands.hub._hub_api", return_value=mock_resp
524 ):
525 result = runner.invoke(cli, ["hub", "repo", "update", "--description", "x"])
526 assert result.exit_code != 2, f"'update' is not a recognised subcommand: {result.output}"
527
528 def test_repo_settings_subcommand_removed(self, repo: pathlib.Path) -> None:
529 """'settings' must no longer be accepted."""
530 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
531 result = runner.invoke(cli, ["hub", "repo", "settings"])
532 assert result.exit_code != 0
533
534
535 # ---------------------------------------------------------------------------
536 # muse hub repo transfer-ownership (renamed from "transfer")
537 # ---------------------------------------------------------------------------
538
539
540 class TestHubRepoTransferOwnershipCommand:
541 """'muse hub repo transfer-ownership' is the canonical verb — 'transfer' is gone."""
542
543 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
544 set_hub_url(hub_url, repo)
545 identity = IdentityEntry(
546 type="human",
547 handle="gabriel",
548 key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"),
549 algorithm="ed25519",
550 fingerprint="deadbeef",
551 )
552 save_identity(hub_url, identity)
553
554 def test_repo_transfer_ownership_subcommand_exists(self, repo: pathlib.Path) -> None:
555 """'transfer-ownership' must be a valid repo subcommand — exit code must not be 2."""
556 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
557 mock_resp = {"repoId": "abc", "name": "muse", "owner": "bob"}
558 with unittest.mock.patch(
559 "muse.cli.commands.hub._hub_api", return_value=mock_resp
560 ):
561 result = runner.invoke(cli, ["hub", "repo", "transfer-ownership", "--new-owner", "bob"])
562 assert result.exit_code != 2, f"'transfer-ownership' is not a recognised subcommand: {result.output}"
563
564 def test_repo_transfer_subcommand_removed(self, repo: pathlib.Path) -> None:
565 """'transfer' must no longer be accepted."""
566 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
567 result = runner.invoke(cli, ["hub", "repo", "transfer", "--new-owner", "bob"])
568 assert result.exit_code != 0
569
570
571 # ---------------------------------------------------------------------------
572 # muse hub collaborator update-permission (renamed from "update")
573 # ---------------------------------------------------------------------------
574
575
576 class TestHubCollaboratorUpdatePermissionCommand:
577 """'muse hub collaborator update-permission' is the canonical verb — 'update' is gone."""
578
579 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
580 set_hub_url(hub_url, repo)
581 identity = IdentityEntry(
582 type="human",
583 handle="gabriel",
584 key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"),
585 algorithm="ed25519",
586 fingerprint="deadbeef",
587 )
588 save_identity(hub_url, identity)
589
590 def test_collaborator_update_permission_subcommand_exists(self, repo: pathlib.Path) -> None:
591 """'update-permission' must be a valid collaborator subcommand — exit code must not be 2."""
592 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
593 mock_resp = {"handle": "carol", "permission": "admin"}
594 with unittest.mock.patch(
595 "muse.cli.commands.hub._hub_api", return_value=mock_resp
596 ):
597 result = runner.invoke(
598 cli, ["hub", "collaborator", "update-permission", "carol", "--permission", "admin"]
599 )
600 assert result.exit_code != 2, f"'update-permission' is not a recognised subcommand: {result.output}"
601
602 def test_collaborator_update_subcommand_removed(self, repo: pathlib.Path) -> None:
603 """'update' must no longer be accepted as a collaborator subcommand."""
604 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
605 result = runner.invoke(
606 cli, ["hub", "collaborator", "update", "carol", "--permission", "admin"]
607 )
608 assert result.exit_code != 0
609
610
611 # ---------------------------------------------------------------------------
612 # muse hub repo list
613 # ---------------------------------------------------------------------------
614
615
616 class TestHubRepoListCommand:
617 """'muse hub repo list' lists repos for the authenticated user."""
618
619 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
620 set_hub_url(hub_url, repo)
621 identity = IdentityEntry(
622 type="human",
623 handle="gabriel",
624 key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"),
625 algorithm="ed25519",
626 fingerprint="deadbeef",
627 )
628 save_identity(hub_url, identity)
629
630 def test_repo_list_subcommand_exists(self, repo: pathlib.Path) -> None:
631 """'list' must be a valid repo subcommand — exit code must not be 2."""
632 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
633 mock_resp = {
634 "total": 1,
635 "nextCursor": None,
636 "repos": [
637 {
638 "repoId": "abc",
639 "name": "my-repo",
640 "owner": "gabriel",
641 "slug": "my-repo",
642 "visibility": "public",
643 "description": "Test repo",
644 "tags": [],
645 "defaultBranch": "main",
646 "createdAt": "2026-01-01T00:00:00Z",
647 "pushedAt": "",
648 }
649 ],
650 }
651 with unittest.mock.patch("muse.cli.commands.hub._hub_api", return_value=mock_resp):
652 result = runner.invoke(cli, ["hub", "repo", "list"])
653 assert result.exit_code != 2, f"'list' is not a recognised subcommand: {result.output}"
654
655 def test_repo_list_json_output_structure(self, repo: pathlib.Path) -> None:
656 """--json emits total, next_cursor, and repos list to stdout."""
657 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
658 mock_resp = {
659 "total": 2,
660 "nextCursor": None,
661 "repos": [
662 {
663 "repoId": "id1",
664 "name": "alpha",
665 "owner": "gabriel",
666 "slug": "alpha",
667 "visibility": "public",
668 "description": "",
669 "tags": [],
670 "defaultBranch": "main",
671 "createdAt": "2026-01-01T00:00:00Z",
672 "pushedAt": "",
673 },
674 {
675 "repoId": "id2",
676 "name": "beta",
677 "owner": "gabriel",
678 "slug": "beta",
679 "visibility": "private",
680 "description": "private repo",
681 "tags": ["music"],
682 "defaultBranch": "dev",
683 "createdAt": "2026-01-02T00:00:00Z",
684 "pushedAt": "",
685 },
686 ],
687 }
688 with unittest.mock.patch("muse.cli.commands.hub._hub_api", return_value=mock_resp):
689 result = runner.invoke(cli, ["hub", "repo", "list", "--json"])
690
691 assert result.exit_code == 0, result.output
692 out = json.loads(result.output)
693 assert out["total"] == 2
694 assert out["next_cursor"] is None
695 assert len(out["repos"]) == 2
696 slugs = [r["slug"] for r in out["repos"]]
697 assert "alpha" in slugs
698 assert "beta" in slugs
699
700 def test_repo_list_json_fields_present(self, repo: pathlib.Path) -> None:
701 """Each repo in --json output has all documented fields."""
702 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
703 mock_resp = {
704 "total": 1,
705 "nextCursor": None,
706 "repos": [
707 {
708 "repoId": "abc",
709 "name": "check-fields",
710 "owner": "gabriel",
711 "slug": "check-fields",
712 "visibility": "public",
713 "description": "desc",
714 "tags": ["a", "b"],
715 "defaultBranch": "main",
716 "createdAt": "2026-01-01T00:00:00Z",
717 "pushedAt": "2026-03-01T00:00:00Z",
718 }
719 ],
720 }
721 with unittest.mock.patch("muse.cli.commands.hub._hub_api", return_value=mock_resp):
722 result = runner.invoke(cli, ["hub", "repo", "list", "--json"])
723
724 assert result.exit_code == 0
725 repos = json.loads(result.output)["repos"]
726 assert len(repos) == 1
727 for field in ("repo_id", "name", "owner", "slug", "visibility",
728 "description", "tags", "default_branch", "created_at", "pushed_at"):
729 assert field in repos[0], f"missing field: {field}"
730
731 def test_repo_list_passes_limit_to_api(self, repo: pathlib.Path) -> None:
732 """--limit is forwarded as a query parameter."""
733 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
734 captured: list[str] = []
735
736 def fake_api(hub_url: str, identity: object, method: str, path: str, **kwargs: object) -> object:
737 captured.append(path)
738 return {"total": 0, "nextCursor": None, "repos": []}
739
740 with unittest.mock.patch("muse.cli.commands.hub._hub_api", side_effect=fake_api):
741 runner.invoke(cli, ["hub", "repo", "list", "--limit", "42", "--json"])
742
743 assert captured, "no API call made"
744 assert "limit=42" in captured[0]
745
746 def test_repo_list_empty_prints_no_repos(self, repo: pathlib.Path) -> None:
747 """Empty repo list exits 0 and does not crash."""
748 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
749 mock_resp = {"total": 0, "nextCursor": None, "repos": []}
750 with unittest.mock.patch("muse.cli.commands.hub._hub_api", return_value=mock_resp):
751 result = runner.invoke(cli, ["hub", "repo", "list"])
752 assert result.exit_code == 0
753
754
755 # ---------------------------------------------------------------------------
756 # muse hub repo show
757 # ---------------------------------------------------------------------------
758
759
760 class TestHubRepoShowCommand:
761 """'muse hub repo show' fetches metadata for a single repo."""
762
763 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
764 set_hub_url(hub_url, repo)
765 identity = IdentityEntry(
766 type="human",
767 handle="gabriel",
768 key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"),
769 algorithm="ed25519",
770 fingerprint="deadbeef",
771 )
772 save_identity(hub_url, identity)
773
774 _MOCK_REPO = {
775 "repoId": "abc123",
776 "name": "jazz-standards",
777 "owner": "gabriel",
778 "slug": "jazz-standards",
779 "visibility": "public",
780 "description": "A collection of jazz standards",
781 "tags": ["music", "jazz"],
782 "defaultBranch": "main",
783 "cloneUrl": "http://localhost:10003/gabriel/jazz-standards",
784 "createdAt": "2026-01-01T00:00:00Z",
785 "updatedAt": "2026-02-01T00:00:00Z",
786 "pushedAt": "2026-03-01T00:00:00Z",
787 }
788
789 def test_repo_show_subcommand_exists(self, repo: pathlib.Path) -> None:
790 """'show' must be a valid repo subcommand — exit code must not be 2."""
791 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
792 with unittest.mock.patch(
793 "muse.cli.commands.hub._hub_api", return_value=self._MOCK_REPO
794 ):
795 result = runner.invoke(cli, ["hub", "repo", "show", "gabriel/jazz-standards"])
796 assert result.exit_code != 2, f"'show' is not a recognised subcommand: {result.output}"
797
798 def test_repo_show_json_output_structure(self, repo: pathlib.Path) -> None:
799 """--json emits all documented repo fields to stdout."""
800 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
801 with unittest.mock.patch(
802 "muse.cli.commands.hub._hub_api", return_value=self._MOCK_REPO
803 ):
804 result = runner.invoke(
805 cli, ["hub", "repo", "show", "gabriel/jazz-standards", "--json"]
806 )
807
808 assert result.exit_code == 0, result.output
809 out = json.loads(result.output)
810 assert out["repo_id"] == "abc123"
811 assert out["slug"] == "jazz-standards"
812 assert out["owner"] == "gabriel"
813 assert out["visibility"] == "public"
814
815 def test_repo_show_json_fields_present(self, repo: pathlib.Path) -> None:
816 """All documented fields are present in --json output."""
817 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
818 with unittest.mock.patch(
819 "muse.cli.commands.hub._hub_api", return_value=self._MOCK_REPO
820 ):
821 result = runner.invoke(
822 cli, ["hub", "repo", "show", "gabriel/jazz-standards", "--json"]
823 )
824
825 assert result.exit_code == 0
826 out = json.loads(result.output)
827 for field in ("repo_id", "name", "owner", "slug", "visibility",
828 "description", "tags", "default_branch",
829 "clone_url", "created_at", "updated_at", "pushed_at"):
830 assert field in out, f"missing field: {field}"
831
832 def test_repo_show_uses_owner_slug_url(self, repo: pathlib.Path) -> None:
833 """OWNER/SLUG argument resolves via /api/{owner}/{slug} not /api/repos/{id}."""
834 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
835 captured: list[str] = []
836
837 def fake_api(hub_url: str, identity: object, method: str, path: str, **kwargs: object) -> object:
838 captured.append(path)
839 return self._MOCK_REPO
840
841 with unittest.mock.patch("muse.cli.commands.hub._hub_api", side_effect=fake_api):
842 runner.invoke(cli, ["hub", "repo", "show", "gabriel/jazz-standards", "--json"])
843
844 assert captured, "no API call made"
845 assert "/api/gabriel/jazz-standards" in captured[0]
846
847 def test_repo_show_tags_preserved(self, repo: pathlib.Path) -> None:
848 """Tags list is preserved intact in JSON output."""
849 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
850 with unittest.mock.patch(
851 "muse.cli.commands.hub._hub_api", return_value=self._MOCK_REPO
852 ):
853 result = runner.invoke(
854 cli, ["hub", "repo", "show", "gabriel/jazz-standards", "--json"]
855 )
856
857 assert result.exit_code == 0
858 out = json.loads(result.output)
859 assert out["tags"] == ["music", "jazz"]
860
861
862 class TestHubRepoDeleteCommand:
863 """Tests for run_repo_delete with the new TARGET argument."""
864
865 def test_delete_by_uuid_calls_correct_endpoint(self) -> None:
866 """run_repo_delete with a UUID target calls DELETE /api/repos/{uuid}."""
867 import argparse
868 import unittest.mock as mock
869 from muse.cli.commands.hub import run_repo_delete
870
871 repo_id = "a3f2c9d1-0000-0000-0000-000000000001"
872
873 with mock.patch("muse.cli.commands.hub._hub_api", return_value={}) as m_api, \
874 mock.patch("muse.cli.commands.hub._get_hub_and_identity",
875 return_value=("http://localhost:10003", mock.MagicMock())):
876 args = argparse.Namespace(target=repo_id, yes=True, hub=None, json_output=True)
877 run_repo_delete(args)
878
879 calls = [str(c) for c in m_api.call_args_list]
880 assert any(f"/api/repos/{repo_id}" in c for c in calls), (
881 f"Expected DELETE /api/repos/{repo_id}, got: {calls}"
882 )
883
884 def test_delete_by_owner_slug_resolves_then_deletes(self) -> None:
885 """run_repo_delete with OWNER/SLUG fetches repo_id then DELETEs it."""
886 import argparse
887 import unittest.mock as mock
888 from muse.cli.commands.hub import run_repo_delete
889
890 resolved_id = "b4e5d6f7-0000-0000-0000-000000000002"
891 get_resp = {"repoId": resolved_id, "name": "my-repo", "owner": "gabriel"}
892
893 with mock.patch("muse.cli.commands.hub._hub_api",
894 side_effect=[get_resp, {}]) as m_api, \
895 mock.patch("muse.cli.commands.hub._get_hub_and_identity",
896 return_value=("http://localhost:10003", mock.MagicMock())):
897 args = argparse.Namespace(target="gabriel/my-repo", yes=True,
898 hub=None, json_output=True)
899 run_repo_delete(args)
900
901 calls = m_api.call_args_list
902 assert len(calls) == 2
903 # First: GET to resolve owner/slug
904 assert calls[0].args[2] == "GET"
905 assert "/api/gabriel/my-repo" in calls[0].args[3]
906 # Second: DELETE with resolved UUID
907 assert calls[1].args[2] == "DELETE"
908 assert f"/api/repos/{resolved_id}" in calls[1].args[3]
909
910 def test_delete_without_yes_exits_nonzero_and_skips_api(self) -> None:
911 """Without --yes, exits non-zero and never calls the API."""
912 import argparse
913 import pytest
914 import unittest.mock as mock
915 from muse.cli.commands.hub import run_repo_delete
916
917 with mock.patch("muse.cli.commands.hub._hub_api") as m_api, \
918 mock.patch("muse.cli.commands.hub._get_hub_and_identity",
919 return_value=("http://localhost:10003", mock.MagicMock())):
920 args = argparse.Namespace(target="gabriel/my-repo", yes=False,
921 hub=None, json_output=False)
922 with pytest.raises(SystemExit) as exc_info:
923 run_repo_delete(args)
924
925 assert exc_info.value.code != 0
926 m_api.assert_not_called()
927
928 def test_delete_no_target_falls_back_to_config_resolution(self) -> None:
929 """When target is None, repo_id is resolved from the current directory config."""
930 import argparse
931 import unittest.mock as mock
932 from muse.cli.commands.hub import run_repo_delete
933
934 config_repo_id = "c5f6e7a8-0000-0000-0000-000000000003"
935
936 with mock.patch("muse.cli.commands.hub._hub_api", return_value={}) as m_api, \
937 mock.patch("muse.cli.commands.hub._get_hub_and_identity",
938 return_value=("http://localhost:10003", mock.MagicMock())), \
939 mock.patch("muse.cli.commands.hub._resolve_repo_id",
940 return_value=config_repo_id):
941 args = argparse.Namespace(target=None, yes=True, hub=None, json_output=True)
942 run_repo_delete(args)
943
944 calls = [str(c) for c in m_api.call_args_list]
945 assert any(f"/api/repos/{config_repo_id}" in c for c in calls), (
946 f"Expected DELETE using config repo_id, got: {calls}"
947 )
948
949 def test_delete_json_output_emits_structured_result(self) -> None:
950 """--json flag emits {deleted: true, repo_id: ...} to stdout."""
951 import argparse
952 import io
953 import json as json_mod
954 import sys
955 import unittest.mock as mock
956 from muse.cli.commands.hub import run_repo_delete
957
958 repo_id = "d6g7h8i9-0000-0000-0000-000000000004"
959
960 with mock.patch("muse.cli.commands.hub._hub_api", return_value={}), \
961 mock.patch("muse.cli.commands.hub._get_hub_and_identity",
962 return_value=("http://localhost:10003", mock.MagicMock())):
963 args = argparse.Namespace(target=repo_id, yes=True, hub=None, json_output=True)
964 captured = io.StringIO()
965 with mock.patch("sys.stdout", captured):
966 run_repo_delete(args)
967
968 output = json_mod.loads(captured.getvalue())
969 assert output["deleted"] is True
970 assert output["repo_id"] == repo_id
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago