test_clone.py
python
| 1 | """Tests for ``muse clone``. |
| 2 | |
| 3 | Covers acceptance criteria: |
| 4 | - ``muse clone <url>`` creates a new directory with .muse/ initialised. |
| 5 | - ``muse clone <url> my-project`` creates ./my-project/. |
| 6 | - Directory name is derived from the URL when no directory argument is given. |
| 7 | - ``--depth N`` is forwarded to the Hub in the clone request. |
| 8 | - ``--branch feature/guitar`` is forwarded and applied to HEAD. |
| 9 | - ``--single-track drums`` is forwarded in the request payload. |
| 10 | - ``--no-checkout`` skips creation of muse-work/. |
| 11 | - ``.muse/config.toml`` has origin remote set to source URL. |
| 12 | - Commits returned from Hub are stored in local Postgres. |
| 13 | - Remote tracking pointer is written after a successful clone. |
| 14 | - Cloning into an existing directory exits 1 with an instructive message. |
| 15 | |
| 16 | All HTTP calls are mocked — no live network required. |
| 17 | DB calls are mocked for integration tests; unit-level DB tests use the |
| 18 | in-memory SQLite fixture from conftest.py. |
| 19 | """ |
| 20 | from __future__ import annotations |
| 21 | |
| 22 | import asyncio |
| 23 | import json |
| 24 | import pathlib |
| 25 | from unittest.mock import AsyncMock, MagicMock, patch |
| 26 | |
| 27 | import pytest |
| 28 | |
| 29 | from maestro.muse_cli.commands.clone import ( |
| 30 | _clone_async, |
| 31 | _derive_directory_name, |
| 32 | _init_muse_dir, |
| 33 | ) |
| 34 | from maestro.muse_cli.config import get_remote, get_remote_head |
| 35 | from maestro.muse_cli.db import store_pulled_commit |
| 36 | from maestro.muse_cli.errors import ExitCode |
| 37 | |
| 38 | |
| 39 | # --------------------------------------------------------------------------- |
| 40 | # Helpers |
| 41 | # --------------------------------------------------------------------------- |
| 42 | |
| 43 | |
| 44 | def _make_clone_response( |
| 45 | repo_id: str = "hub-repo-abc123", |
| 46 | default_branch: str = "main", |
| 47 | remote_head: str | None = "remote-commit-001", |
| 48 | commits: list[dict[str, object]] | None = None, |
| 49 | objects: list[dict[str, object]] | None = None, |
| 50 | ) -> MagicMock: |
| 51 | """Return a mock httpx.Response for the clone endpoint.""" |
| 52 | mock_resp = MagicMock() |
| 53 | mock_resp.status_code = 200 |
| 54 | mock_resp.json.return_value = { |
| 55 | "repo_id": repo_id, |
| 56 | "default_branch": default_branch, |
| 57 | "remote_head": remote_head, |
| 58 | "commits": commits or [], |
| 59 | "objects": objects or [], |
| 60 | } |
| 61 | return mock_resp |
| 62 | |
| 63 | |
| 64 | def _write_token_config(root: pathlib.Path, url: str) -> None: |
| 65 | """Write a config.toml with auth token and origin remote.""" |
| 66 | muse_dir = root / ".muse" |
| 67 | muse_dir.mkdir(parents=True, exist_ok=True) |
| 68 | (muse_dir / "config.toml").write_text( |
| 69 | f'[auth]\ntoken = "test-token"\n\n[remotes.origin]\nurl = "{url}"\n', |
| 70 | encoding="utf-8", |
| 71 | ) |
| 72 | |
| 73 | |
| 74 | def _mock_hub(mock_response: MagicMock) -> MagicMock: |
| 75 | """Build a mock MuseHubClient that returns *mock_response* for POST /clone.""" |
| 76 | hub = MagicMock() |
| 77 | hub.__aenter__ = AsyncMock(return_value=hub) |
| 78 | hub.__aexit__ = AsyncMock(return_value=None) |
| 79 | hub.post = AsyncMock(return_value=mock_response) |
| 80 | return hub |
| 81 | |
| 82 | |
| 83 | def _run_clone( |
| 84 | *, |
| 85 | url: str, |
| 86 | directory: str | None, |
| 87 | depth: int | None = None, |
| 88 | branch: str | None = None, |
| 89 | single_track: str | None = None, |
| 90 | no_checkout: bool = False, |
| 91 | mock_response: MagicMock | None = None, |
| 92 | ) -> None: |
| 93 | """Run _clone_async with all DB/HTTP layers mocked.""" |
| 94 | response = mock_response or _make_clone_response() |
| 95 | mock_hub = _mock_hub(response) |
| 96 | |
| 97 | mock_session_ctx = MagicMock() |
| 98 | mock_session_ctx.__aenter__ = AsyncMock(return_value=MagicMock()) |
| 99 | mock_session_ctx.__aexit__ = AsyncMock(return_value=None) |
| 100 | |
| 101 | with ( |
| 102 | patch( |
| 103 | "maestro.muse_cli.commands.clone.store_pulled_commit", |
| 104 | new=AsyncMock(return_value=False), |
| 105 | ), |
| 106 | patch( |
| 107 | "maestro.muse_cli.commands.clone.store_pulled_object", |
| 108 | new=AsyncMock(return_value=False), |
| 109 | ), |
| 110 | patch("maestro.muse_cli.commands.clone.open_session", return_value=mock_session_ctx), |
| 111 | patch("maestro.muse_cli.commands.clone.MuseHubClient", return_value=mock_hub), |
| 112 | ): |
| 113 | asyncio.run( |
| 114 | _clone_async( |
| 115 | url=url, |
| 116 | directory=directory, |
| 117 | depth=depth, |
| 118 | branch=branch, |
| 119 | single_track=single_track, |
| 120 | no_checkout=no_checkout, |
| 121 | ) |
| 122 | ) |
| 123 | |
| 124 | |
| 125 | # --------------------------------------------------------------------------- |
| 126 | # Unit tests: _derive_directory_name |
| 127 | # --------------------------------------------------------------------------- |
| 128 | |
| 129 | |
| 130 | def test_derive_directory_name_simple_path() -> None: |
| 131 | """Last URL path component is used as directory name.""" |
| 132 | assert _derive_directory_name("https://hub.stori.app/repos/my-project") == "my-project" |
| 133 | |
| 134 | |
| 135 | def test_derive_directory_name_trailing_slash() -> None: |
| 136 | """Trailing slashes are stripped before extraction.""" |
| 137 | assert _derive_directory_name("https://hub.stori.app/repos/my-project/") == "my-project" |
| 138 | |
| 139 | |
| 140 | def test_derive_directory_name_bare_root() -> None: |
| 141 | """Fallback to 'muse-clone' when URL has no useful last segment.""" |
| 142 | assert _derive_directory_name("https://hub.stori.app/repos") == "muse-clone" |
| 143 | |
| 144 | |
| 145 | def test_derive_directory_name_with_uuid_segment() -> None: |
| 146 | """UUID-style repo paths preserve the identifier.""" |
| 147 | result = _derive_directory_name("https://hub.stori.app/repos/abc-123-def") |
| 148 | assert result == "abc-123-def" |
| 149 | |
| 150 | |
| 151 | # --------------------------------------------------------------------------- |
| 152 | # Unit tests: _init_muse_dir |
| 153 | # --------------------------------------------------------------------------- |
| 154 | |
| 155 | |
| 156 | def test_init_muse_dir_creates_dot_muse(tmp_path: pathlib.Path) -> None: |
| 157 | """_init_muse_dir creates the .muse/ directory tree.""" |
| 158 | _init_muse_dir(tmp_path, "main", "https://hub.stori.app/repos/foo") |
| 159 | assert (tmp_path / ".muse").is_dir() |
| 160 | assert (tmp_path / ".muse" / "refs" / "heads").is_dir() |
| 161 | assert (tmp_path / ".muse" / "repo.json").is_file() |
| 162 | assert (tmp_path / ".muse" / "HEAD").is_file() |
| 163 | assert (tmp_path / ".muse" / "config.toml").is_file() |
| 164 | |
| 165 | |
| 166 | def test_init_muse_dir_head_points_at_branch(tmp_path: pathlib.Path) -> None: |
| 167 | """HEAD is written as refs/heads/<branch>.""" |
| 168 | _init_muse_dir(tmp_path, "feature/guitar", "https://hub.example.com/r") |
| 169 | head = (tmp_path / ".muse" / "HEAD").read_text(encoding="utf-8").strip() |
| 170 | assert head == "refs/heads/feature/guitar" |
| 171 | |
| 172 | |
| 173 | def test_init_muse_dir_origin_remote_set(tmp_path: pathlib.Path) -> None: |
| 174 | """Origin remote URL is written to config.toml.""" |
| 175 | url = "https://hub.stori.app/repos/my-project" |
| 176 | _init_muse_dir(tmp_path, "main", url) |
| 177 | stored = get_remote("origin", tmp_path) |
| 178 | assert stored == url |
| 179 | |
| 180 | |
| 181 | def test_init_muse_dir_repo_json_has_schema_version(tmp_path: pathlib.Path) -> None: |
| 182 | """repo.json contains schema_version field.""" |
| 183 | _init_muse_dir(tmp_path, "main", "https://hub.example.com/r") |
| 184 | data = json.loads((tmp_path / ".muse" / "repo.json").read_text()) |
| 185 | assert "schema_version" in data |
| 186 | assert "repo_id" in data |
| 187 | |
| 188 | |
| 189 | # --------------------------------------------------------------------------- |
| 190 | # Regression test: test_clone_creates_repo_with_origin_remote |
| 191 | # --------------------------------------------------------------------------- |
| 192 | |
| 193 | |
| 194 | def test_clone_creates_repo_with_origin_remote(tmp_path: pathlib.Path) -> None: |
| 195 | """muse clone creates .muse/ and sets origin remote to the source URL. |
| 196 | |
| 197 | Regression — collaborators had no way to create a local |
| 198 | copy of a remote Muse Hub repo from scratch. |
| 199 | """ |
| 200 | url = "https://hub.stori.app/repos/my-project" |
| 201 | target = tmp_path / "my-project" |
| 202 | |
| 203 | _run_clone(url=url, directory=str(target)) |
| 204 | |
| 205 | assert target.is_dir() |
| 206 | assert (target / ".muse").is_dir() |
| 207 | stored_origin = get_remote("origin", target) |
| 208 | assert stored_origin == url |
| 209 | |
| 210 | |
| 211 | def test_clone_creates_directory_from_url_name(tmp_path: pathlib.Path) -> None: |
| 212 | """muse clone without explicit directory uses URL last component as name.""" |
| 213 | import os |
| 214 | |
| 215 | url = "https://hub.stori.app/repos/producer-beats" |
| 216 | |
| 217 | old_cwd = os.getcwd() |
| 218 | try: |
| 219 | os.chdir(tmp_path) |
| 220 | _run_clone(url=url, directory=None) |
| 221 | finally: |
| 222 | os.chdir(old_cwd) |
| 223 | |
| 224 | assert (tmp_path / "producer-beats").is_dir() |
| 225 | assert (tmp_path / "producer-beats" / ".muse").is_dir() |
| 226 | |
| 227 | |
| 228 | def test_clone_explicit_directory(tmp_path: pathlib.Path) -> None: |
| 229 | """muse clone <url> my-project creates ./my-project/.""" |
| 230 | url = "https://hub.stori.app/repos/some-repo" |
| 231 | target = tmp_path / "my-custom-name" |
| 232 | |
| 233 | _run_clone(url=url, directory=str(target)) |
| 234 | |
| 235 | assert target.is_dir() |
| 236 | assert (target / ".muse").is_dir() |
| 237 | |
| 238 | |
| 239 | def test_clone_existing_directory_exits_1(tmp_path: pathlib.Path) -> None: |
| 240 | """muse clone into an existing directory exits 1.""" |
| 241 | import typer |
| 242 | |
| 243 | url = "https://hub.stori.app/repos/foo" |
| 244 | existing = tmp_path / "foo" |
| 245 | existing.mkdir() |
| 246 | |
| 247 | with pytest.raises(typer.Exit) as exc_info: |
| 248 | _run_clone(url=url, directory=str(existing)) |
| 249 | |
| 250 | assert exc_info.value.exit_code == int(ExitCode.USER_ERROR) |
| 251 | |
| 252 | |
| 253 | def test_clone_existing_directory_message( |
| 254 | tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str] |
| 255 | ) -> None: |
| 256 | """Clone into existing directory prints an instructive error.""" |
| 257 | import typer |
| 258 | |
| 259 | url = "https://hub.stori.app/repos/foo" |
| 260 | existing = tmp_path / "foo" |
| 261 | existing.mkdir() |
| 262 | |
| 263 | with pytest.raises(typer.Exit): |
| 264 | _run_clone(url=url, directory=str(existing)) |
| 265 | |
| 266 | captured = capsys.readouterr() |
| 267 | assert "already exists" in captured.out |
| 268 | |
| 269 | |
| 270 | # --------------------------------------------------------------------------- |
| 271 | # Hub interaction tests |
| 272 | # --------------------------------------------------------------------------- |
| 273 | |
| 274 | |
| 275 | @pytest.mark.anyio |
| 276 | async def test_clone_calls_hub_post_clone(tmp_path: pathlib.Path) -> None: |
| 277 | """muse clone POSTs to /clone with the correct payload fields.""" |
| 278 | url = "https://hub.stori.app/repos/collab-track" |
| 279 | target = tmp_path / "collab-track" |
| 280 | |
| 281 | captured_payloads: list[dict[str, object]] = [] |
| 282 | mock_response = _make_clone_response() |
| 283 | |
| 284 | hub = MagicMock() |
| 285 | hub.__aenter__ = AsyncMock(return_value=hub) |
| 286 | hub.__aexit__ = AsyncMock(return_value=None) |
| 287 | |
| 288 | async def fake_post(path: str, **kwargs: object) -> MagicMock: |
| 289 | payload = kwargs.get("json", {}) |
| 290 | if isinstance(payload, dict): |
| 291 | captured_payloads.append(payload) |
| 292 | return mock_response |
| 293 | |
| 294 | hub.post = fake_post |
| 295 | |
| 296 | mock_session_ctx = MagicMock() |
| 297 | mock_session_ctx.__aenter__ = AsyncMock(return_value=MagicMock()) |
| 298 | mock_session_ctx.__aexit__ = AsyncMock(return_value=None) |
| 299 | |
| 300 | with ( |
| 301 | patch( |
| 302 | "maestro.muse_cli.commands.clone.store_pulled_commit", |
| 303 | new=AsyncMock(return_value=False), |
| 304 | ), |
| 305 | patch( |
| 306 | "maestro.muse_cli.commands.clone.store_pulled_object", |
| 307 | new=AsyncMock(return_value=False), |
| 308 | ), |
| 309 | patch("maestro.muse_cli.commands.clone.open_session", return_value=mock_session_ctx), |
| 310 | patch("maestro.muse_cli.commands.clone.MuseHubClient", return_value=hub), |
| 311 | ): |
| 312 | await _clone_async( |
| 313 | url=url, |
| 314 | directory=str(target), |
| 315 | depth=5, |
| 316 | branch="main", |
| 317 | single_track="drums", |
| 318 | no_checkout=False, |
| 319 | ) |
| 320 | |
| 321 | assert len(captured_payloads) == 1 |
| 322 | payload = captured_payloads[0] |
| 323 | assert payload["depth"] == 5 |
| 324 | assert payload["branch"] == "main" |
| 325 | assert payload["single_track"] == "drums" |
| 326 | |
| 327 | |
| 328 | def test_clone_depth_forwarded_in_request(tmp_path: pathlib.Path) -> None: |
| 329 | """--depth N is forwarded to Hub in the clone request payload.""" |
| 330 | url = "https://hub.stori.app/repos/shallow-repo" |
| 331 | target = tmp_path / "shallow-repo" |
| 332 | |
| 333 | captured: list[dict[str, object]] = [] |
| 334 | mock_response = _make_clone_response() |
| 335 | |
| 336 | hub = MagicMock() |
| 337 | hub.__aenter__ = AsyncMock(return_value=hub) |
| 338 | hub.__aexit__ = AsyncMock(return_value=None) |
| 339 | |
| 340 | async def fake_post(path: str, **kwargs: object) -> MagicMock: |
| 341 | payload = kwargs.get("json", {}) |
| 342 | if isinstance(payload, dict): |
| 343 | captured.append(payload) |
| 344 | return mock_response |
| 345 | |
| 346 | hub.post = fake_post |
| 347 | |
| 348 | mock_session_ctx = MagicMock() |
| 349 | mock_session_ctx.__aenter__ = AsyncMock(return_value=MagicMock()) |
| 350 | mock_session_ctx.__aexit__ = AsyncMock(return_value=None) |
| 351 | |
| 352 | with ( |
| 353 | patch( |
| 354 | "maestro.muse_cli.commands.clone.store_pulled_commit", |
| 355 | new=AsyncMock(return_value=False), |
| 356 | ), |
| 357 | patch( |
| 358 | "maestro.muse_cli.commands.clone.store_pulled_object", |
| 359 | new=AsyncMock(return_value=False), |
| 360 | ), |
| 361 | patch("maestro.muse_cli.commands.clone.open_session", return_value=mock_session_ctx), |
| 362 | patch("maestro.muse_cli.commands.clone.MuseHubClient", return_value=hub), |
| 363 | ): |
| 364 | asyncio.run( |
| 365 | _clone_async( |
| 366 | url=url, |
| 367 | directory=str(target), |
| 368 | depth=1, |
| 369 | branch=None, |
| 370 | single_track=None, |
| 371 | no_checkout=False, |
| 372 | ) |
| 373 | ) |
| 374 | |
| 375 | assert captured[0]["depth"] == 1 |
| 376 | |
| 377 | |
| 378 | def test_clone_single_track_forwarded(tmp_path: pathlib.Path) -> None: |
| 379 | """--single-track TEXT is forwarded to Hub in the clone request.""" |
| 380 | url = "https://hub.stori.app/repos/full-band" |
| 381 | target = tmp_path / "full-band" |
| 382 | |
| 383 | captured: list[dict[str, object]] = [] |
| 384 | mock_response = _make_clone_response() |
| 385 | |
| 386 | hub = MagicMock() |
| 387 | hub.__aenter__ = AsyncMock(return_value=hub) |
| 388 | hub.__aexit__ = AsyncMock(return_value=None) |
| 389 | |
| 390 | async def fake_post(path: str, **kwargs: object) -> MagicMock: |
| 391 | payload = kwargs.get("json", {}) |
| 392 | if isinstance(payload, dict): |
| 393 | captured.append(payload) |
| 394 | return mock_response |
| 395 | |
| 396 | hub.post = fake_post |
| 397 | |
| 398 | mock_session_ctx = MagicMock() |
| 399 | mock_session_ctx.__aenter__ = AsyncMock(return_value=MagicMock()) |
| 400 | mock_session_ctx.__aexit__ = AsyncMock(return_value=None) |
| 401 | |
| 402 | with ( |
| 403 | patch( |
| 404 | "maestro.muse_cli.commands.clone.store_pulled_commit", |
| 405 | new=AsyncMock(return_value=False), |
| 406 | ), |
| 407 | patch( |
| 408 | "maestro.muse_cli.commands.clone.store_pulled_object", |
| 409 | new=AsyncMock(return_value=False), |
| 410 | ), |
| 411 | patch("maestro.muse_cli.commands.clone.open_session", return_value=mock_session_ctx), |
| 412 | patch("maestro.muse_cli.commands.clone.MuseHubClient", return_value=hub), |
| 413 | ): |
| 414 | asyncio.run( |
| 415 | _clone_async( |
| 416 | url=url, |
| 417 | directory=str(target), |
| 418 | depth=None, |
| 419 | branch=None, |
| 420 | single_track="keys", |
| 421 | no_checkout=False, |
| 422 | ) |
| 423 | ) |
| 424 | |
| 425 | assert captured[0]["single_track"] == "keys" |
| 426 | |
| 427 | |
| 428 | # --------------------------------------------------------------------------- |
| 429 | # Filesystem structure tests |
| 430 | # --------------------------------------------------------------------------- |
| 431 | |
| 432 | |
| 433 | def test_clone_writes_repo_id_from_hub(tmp_path: pathlib.Path) -> None: |
| 434 | """repo_id returned by Hub is written into .muse/repo.json.""" |
| 435 | url = "https://hub.stori.app/repos/hub-id-test" |
| 436 | target = tmp_path / "hub-id-test" |
| 437 | response = _make_clone_response(repo_id="canonical-hub-uuid-9999") |
| 438 | |
| 439 | _run_clone(url=url, directory=str(target), mock_response=response) |
| 440 | |
| 441 | data = json.loads((target / ".muse" / "repo.json").read_text()) |
| 442 | assert data["repo_id"] == "canonical-hub-uuid-9999" |
| 443 | |
| 444 | |
| 445 | def test_clone_updates_branch_ref_with_remote_head(tmp_path: pathlib.Path) -> None: |
| 446 | """After clone, .muse/refs/heads/<branch> contains the remote HEAD commit ID.""" |
| 447 | url = "https://hub.stori.app/repos/ref-test" |
| 448 | target = tmp_path / "ref-test" |
| 449 | head_id = "commit-abc123def456" * 3 |
| 450 | response = _make_clone_response(remote_head=head_id, default_branch="main") |
| 451 | |
| 452 | _run_clone(url=url, directory=str(target), mock_response=response) |
| 453 | |
| 454 | ref_path = target / ".muse" / "refs" / "heads" / "main" |
| 455 | assert ref_path.is_file() |
| 456 | assert ref_path.read_text(encoding="utf-8").strip() == head_id |
| 457 | |
| 458 | |
| 459 | def test_clone_writes_remote_tracking_head(tmp_path: pathlib.Path) -> None: |
| 460 | """After clone, .muse/remotes/origin/<branch> is written with remote HEAD.""" |
| 461 | url = "https://hub.stori.app/repos/tracking-test" |
| 462 | target = tmp_path / "tracking-test" |
| 463 | head_id = "tracking-head-id99887766" * 2 |
| 464 | response = _make_clone_response(remote_head=head_id, default_branch="main") |
| 465 | |
| 466 | _run_clone(url=url, directory=str(target), mock_response=response) |
| 467 | |
| 468 | stored = get_remote_head("origin", "main", target) |
| 469 | assert stored == head_id |
| 470 | |
| 471 | |
| 472 | def test_clone_no_checkout_skips_muse_work(tmp_path: pathlib.Path) -> None: |
| 473 | """--no-checkout leaves muse-work/ unpopulated.""" |
| 474 | url = "https://hub.stori.app/repos/no-co-test" |
| 475 | target = tmp_path / "no-co-test" |
| 476 | |
| 477 | _run_clone(url=url, directory=str(target), no_checkout=True) |
| 478 | |
| 479 | assert not (target / "muse-work").is_dir() |
| 480 | |
| 481 | |
| 482 | def test_clone_without_no_checkout_creates_muse_work(tmp_path: pathlib.Path) -> None: |
| 483 | """Without --no-checkout, muse-work/ is created.""" |
| 484 | url = "https://hub.stori.app/repos/co-test" |
| 485 | target = tmp_path / "co-test" |
| 486 | |
| 487 | _run_clone(url=url, directory=str(target), no_checkout=False) |
| 488 | |
| 489 | assert (target / "muse-work").is_dir() |
| 490 | |
| 491 | |
| 492 | def test_clone_branch_sets_head(tmp_path: pathlib.Path) -> None: |
| 493 | """--branch sets HEAD to the specified branch name.""" |
| 494 | url = "https://hub.stori.app/repos/branch-test" |
| 495 | target = tmp_path / "branch-test" |
| 496 | response = _make_clone_response(default_branch="feature/guitar") |
| 497 | |
| 498 | _run_clone(url=url, directory=str(target), branch="feature/guitar", mock_response=response) |
| 499 | |
| 500 | head = (target / ".muse" / "HEAD").read_text(encoding="utf-8").strip() |
| 501 | assert head == "refs/heads/feature/guitar" |
| 502 | |
| 503 | |
| 504 | # --------------------------------------------------------------------------- |
| 505 | # DB storage tests |
| 506 | # --------------------------------------------------------------------------- |
| 507 | |
| 508 | |
| 509 | @pytest.mark.anyio |
| 510 | async def test_clone_stores_commits_in_db(muse_cli_db_session: object) -> None: |
| 511 | """Commits returned from Hub clone are stored via store_pulled_commit.""" |
| 512 | from sqlalchemy.ext.asyncio import AsyncSession |
| 513 | from maestro.muse_cli.models import MuseCliCommit |
| 514 | |
| 515 | session: AsyncSession = muse_cli_db_session # type: ignore[assignment] |
| 516 | |
| 517 | commit_data: dict[str, object] = { |
| 518 | "commit_id": "cloned-commit-aabbcc" * 3, |
| 519 | "repo_id": "test-hub-repo", |
| 520 | "parent_commit_id": None, |
| 521 | "snapshot_id": "snap-clone-001", |
| 522 | "branch": "main", |
| 523 | "message": "Initial commit from Hub", |
| 524 | "author": "producer@example.com", |
| 525 | "committed_at": "2025-03-01T12:00:00+00:00", |
| 526 | "metadata": None, |
| 527 | } |
| 528 | |
| 529 | inserted = await store_pulled_commit(session, commit_data) |
| 530 | await session.commit() |
| 531 | |
| 532 | assert inserted is True |
| 533 | |
| 534 | commit_id = str(commit_data["commit_id"]) |
| 535 | stored = await session.get(MuseCliCommit, commit_id) |
| 536 | assert stored is not None |
| 537 | assert stored.branch == "main" |
| 538 | assert stored.message == "Initial commit from Hub" |
| 539 | |
| 540 | |
| 541 | # --------------------------------------------------------------------------- |
| 542 | # Hub error handling |
| 543 | # --------------------------------------------------------------------------- |
| 544 | |
| 545 | |
| 546 | def test_clone_hub_non_200_exits_3(tmp_path: pathlib.Path) -> None: |
| 547 | """Hub returning non-200 causes muse clone to exit 3 and cleans up the directory.""" |
| 548 | import typer |
| 549 | |
| 550 | url = "https://hub.stori.app/repos/error-test" |
| 551 | target = tmp_path / "error-test" |
| 552 | |
| 553 | error_response = MagicMock() |
| 554 | error_response.status_code = 404 |
| 555 | error_response.text = "Not found" |
| 556 | |
| 557 | hub = _mock_hub(error_response) |
| 558 | |
| 559 | mock_session_ctx = MagicMock() |
| 560 | mock_session_ctx.__aenter__ = AsyncMock(return_value=MagicMock()) |
| 561 | mock_session_ctx.__aexit__ = AsyncMock(return_value=None) |
| 562 | |
| 563 | with ( |
| 564 | patch("maestro.muse_cli.commands.clone.open_session", return_value=mock_session_ctx), |
| 565 | patch("maestro.muse_cli.commands.clone.MuseHubClient", return_value=hub), |
| 566 | pytest.raises(typer.Exit) as exc_info, |
| 567 | ): |
| 568 | asyncio.run( |
| 569 | _clone_async( |
| 570 | url=url, |
| 571 | directory=str(target), |
| 572 | depth=None, |
| 573 | branch=None, |
| 574 | single_track=None, |
| 575 | no_checkout=False, |
| 576 | ) |
| 577 | ) |
| 578 | |
| 579 | assert exc_info.value.exit_code == int(ExitCode.INTERNAL_ERROR) |
| 580 | # Partial directory must be cleaned up so retrying does not hit "already exists". |
| 581 | assert not target.exists() |
| 582 | |
| 583 | |
| 584 | def test_clone_network_error_exits_3(tmp_path: pathlib.Path) -> None: |
| 585 | """Network error during clone causes exit 3 and cleans up the directory.""" |
| 586 | import httpx |
| 587 | import typer |
| 588 | |
| 589 | url = "https://hub.stori.app/repos/net-err" |
| 590 | target = tmp_path / "net-err" |
| 591 | |
| 592 | hub = MagicMock() |
| 593 | hub.__aenter__ = AsyncMock(return_value=hub) |
| 594 | hub.__aexit__ = AsyncMock(return_value=None) |
| 595 | hub.post = AsyncMock(side_effect=httpx.NetworkError("connection refused")) |
| 596 | |
| 597 | mock_session_ctx = MagicMock() |
| 598 | mock_session_ctx.__aenter__ = AsyncMock(return_value=MagicMock()) |
| 599 | mock_session_ctx.__aexit__ = AsyncMock(return_value=None) |
| 600 | |
| 601 | with ( |
| 602 | patch("maestro.muse_cli.commands.clone.open_session", return_value=mock_session_ctx), |
| 603 | patch("maestro.muse_cli.commands.clone.MuseHubClient", return_value=hub), |
| 604 | pytest.raises(typer.Exit) as exc_info, |
| 605 | ): |
| 606 | asyncio.run( |
| 607 | _clone_async( |
| 608 | url=url, |
| 609 | directory=str(target), |
| 610 | depth=None, |
| 611 | branch=None, |
| 612 | single_track=None, |
| 613 | no_checkout=False, |
| 614 | ) |
| 615 | ) |
| 616 | |
| 617 | assert exc_info.value.exit_code == int(ExitCode.INTERNAL_ERROR) |
| 618 | # Partial directory must be cleaned up so retrying does not hit "already exists". |
| 619 | assert not target.exists() |
| 620 | |
| 621 | |
| 622 | # --------------------------------------------------------------------------- |
| 623 | # CLI skeleton test (app registration) |
| 624 | # --------------------------------------------------------------------------- |
| 625 | |
| 626 | |
| 627 | def test_clone_registered_in_cli() -> None: |
| 628 | """'clone' command is registered in the muse CLI app.""" |
| 629 | from typer.testing import CliRunner |
| 630 | from maestro.muse_cli.app import cli |
| 631 | |
| 632 | runner = CliRunner() |
| 633 | result = runner.invoke(cli, ["--help"]) |
| 634 | assert "clone" in result.output |