test_domains_publish.py
python
sha256:18b983389ee1b55900fcd799bfbb496552d2e3ecded9d18cefbfef188947a12e
chore: remove blob-debug test marker file
Sonnet 4.6
1 day ago
| 1 | """Tests for ``muse domains publish`` — the MuseHub marketplace publisher. |
| 2 | |
| 3 | Covers: |
| 4 | Unit tests for ``_post_json`` helper: |
| 5 | - Sends correct HTTP method, URL, headers, and JSON body. |
| 6 | - Returns a typed _PublishResponse on 200. |
| 7 | - Raises HTTPError on non-2xx. |
| 8 | - Raises ValueError on non-object JSON. |
| 9 | |
| 10 | CLI integration tests (via Typer CliRunner, no real HTTP): |
| 11 | - Successful publish with --capabilities JSON emits domain_id / scoped_id. |
| 12 | - Successful publish with --json emits machine-readable JSON. |
| 13 | - Missing required args → UsageError (non-zero exit). |
| 14 | - No auth token → exit 1 with clear message. |
| 15 | - HTTP 409 conflict → exit 1 with "already registered" message. |
| 16 | - HTTP 401 → exit 1 with "Authentication failed" message. |
| 17 | - Network error (URLError) → exit 1 with "Could not reach" message. |
| 18 | - Non-JSON response body → exit 1 with "Unexpected response" message. |
| 19 | - Capabilities derived from plugin schema when --capabilities is omitted. |
| 20 | """ |
| 21 | from __future__ import annotations |
| 22 | |
| 23 | import http.client |
| 24 | import io |
| 25 | import json |
| 26 | import pathlib |
| 27 | import urllib.error |
| 28 | import urllib.request |
| 29 | import urllib.response |
| 30 | import unittest.mock |
| 31 | from typing import TYPE_CHECKING |
| 32 | |
| 33 | import pytest |
| 34 | from tests.cli_test_helper import CliRunner |
| 35 | |
| 36 | from muse._version import __version__ |
| 37 | from muse.core.paths import muse_dir |
| 38 | cli = None # argparse migration — CliRunner ignores this arg |
| 39 | from muse.cli.commands.domains import _post_json, _PublishPayload, _Capabilities, _DimensionDef |
| 40 | |
| 41 | if TYPE_CHECKING: |
| 42 | pass |
| 43 | |
| 44 | runner = CliRunner() |
| 45 | |
| 46 | |
| 47 | def _make_signing() -> "SigningIdentity": |
| 48 | """Generate a fresh Ed25519 SigningIdentity for tests.""" |
| 49 | from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey |
| 50 | from muse.core.transport import SigningIdentity |
| 51 | return SigningIdentity(handle="testuser", private_key=Ed25519PrivateKey.generate()) |
| 52 | |
| 53 | |
| 54 | # --------------------------------------------------------------------------- |
| 55 | # Fixture: minimal Muse repo with auth token |
| 56 | # --------------------------------------------------------------------------- |
| 57 | |
| 58 | _BASE_CAPS_JSON = json.dumps({ |
| 59 | "dimensions": [{"name": "notes", "description": "Note events"}], |
| 60 | "artifact_types": ["mid"], |
| 61 | "merge_semantics": "three_way", |
| 62 | "supported_commands": ["commit", "diff"], |
| 63 | }) |
| 64 | |
| 65 | _REQUIRED_ARGS = [ |
| 66 | "domains", "publish", |
| 67 | "--author", "testuser", |
| 68 | "--slug", "genomics", |
| 69 | "--name", "Genomics", |
| 70 | "--description", "Version DNA sequences", |
| 71 | "--viewer-type", "genome", |
| 72 | "--capabilities", _BASE_CAPS_JSON, |
| 73 | "--hub", "https://hub.test", |
| 74 | ] |
| 75 | |
| 76 | |
| 77 | @pytest.fixture() |
| 78 | def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: |
| 79 | """Minimal .muse/ repo; auth token is mocked via get_signing_identity.""" |
| 80 | dot_muse = muse_dir(tmp_path) |
| 81 | (dot_muse / "refs" / "heads").mkdir(parents=True) |
| 82 | (dot_muse / "objects").mkdir() |
| 83 | (dot_muse / "commits").mkdir() |
| 84 | (dot_muse / "snapshots").mkdir() |
| 85 | (dot_muse / "repo.json").write_text( |
| 86 | json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"}) |
| 87 | ) |
| 88 | (dot_muse / "HEAD").write_text("ref: refs/heads/main\n") |
| 89 | monkeypatch.chdir(tmp_path) |
| 90 | monkeypatch.setattr("muse.cli.commands.domains.get_signing_identity", lambda *a, **kw: _make_signing()) |
| 91 | return tmp_path |
| 92 | |
| 93 | |
| 94 | @pytest.fixture() |
| 95 | def repo_no_token(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: |
| 96 | """Minimal .muse/ repo where get_signing_identity returns None (no token).""" |
| 97 | dot_muse = muse_dir(tmp_path) |
| 98 | (dot_muse / "refs" / "heads").mkdir(parents=True) |
| 99 | (dot_muse / "objects").mkdir() |
| 100 | (dot_muse / "commits").mkdir() |
| 101 | (dot_muse / "snapshots").mkdir() |
| 102 | (dot_muse / "repo.json").write_text( |
| 103 | json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"}) |
| 104 | ) |
| 105 | (dot_muse / "HEAD").write_text("ref: refs/heads/main\n") |
| 106 | monkeypatch.chdir(tmp_path) |
| 107 | monkeypatch.setattr("muse.cli.commands.domains.get_signing_identity", lambda *a, **kw: None) |
| 108 | return tmp_path |
| 109 | |
| 110 | |
| 111 | # --------------------------------------------------------------------------- |
| 112 | # Helper: build a mock urllib response |
| 113 | # --------------------------------------------------------------------------- |
| 114 | |
| 115 | |
| 116 | def _mock_urlopen(response_body: str | bytes, status: int = 200) -> unittest.mock.MagicMock: |
| 117 | """Return a context-manager mock that yields a fake HTTP response.""" |
| 118 | if isinstance(response_body, str): |
| 119 | response_body = response_body.encode() |
| 120 | mock_resp = unittest.mock.MagicMock() |
| 121 | mock_resp.read.return_value = response_body |
| 122 | mock_resp.__enter__ = unittest.mock.MagicMock(return_value=mock_resp) |
| 123 | mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False) |
| 124 | return mock_resp |
| 125 | |
| 126 | |
| 127 | # --------------------------------------------------------------------------- |
| 128 | # Unit tests for _post_json |
| 129 | # --------------------------------------------------------------------------- |
| 130 | |
| 131 | |
| 132 | def test_post_json_sends_correct_request() -> None: |
| 133 | """_post_json should POST JSON to the given URL with Auth header.""" |
| 134 | payload = _PublishPayload( |
| 135 | author_slug="alice", |
| 136 | slug="spatial", |
| 137 | display_name="Spatial 3D", |
| 138 | description="Version 3D scenes", |
| 139 | capabilities=_Capabilities( |
| 140 | dimensions=[_DimensionDef(name="geometry", description="Mesh data")], |
| 141 | artifact_types=["glb"], |
| 142 | merge_semantics="three_way", |
| 143 | supported_commands=["commit"], |
| 144 | ), |
| 145 | viewer_type="spatial", |
| 146 | version="0.1.0", |
| 147 | ) |
| 148 | |
| 149 | captured: list[urllib.request.Request] = [] |
| 150 | |
| 151 | def _fake_urlopen( |
| 152 | req: urllib.request.Request, timeout: float | None = None |
| 153 | ) -> unittest.mock.MagicMock: |
| 154 | captured.append(req) |
| 155 | mock_resp = _mock_urlopen(json.dumps({"domain_id": "d1", "scoped_id": "@alice/spatial", "manifest_hash": "abc"})) |
| 156 | return mock_resp |
| 157 | |
| 158 | with unittest.mock.patch("urllib.request.urlopen", side_effect=_fake_urlopen): |
| 159 | result = _post_json("https://hub.test/api/v1/domains", payload, _make_signing()) |
| 160 | |
| 161 | assert len(captured) == 1 |
| 162 | req = captured[0] |
| 163 | assert req.get_method() == "POST" |
| 164 | assert req.full_url == "https://hub.test/api/v1/domains" |
| 165 | assert req.get_header("Authorization").startswith("MSign handle=\"testuser\"") |
| 166 | assert req.get_header("Content-type") == "application/json" |
| 167 | assert req.data is not None |
| 168 | body = json.loads(req.data.decode()) |
| 169 | assert body["author_slug"] == "alice" |
| 170 | assert body["slug"] == "spatial" |
| 171 | |
| 172 | assert result["scoped_id"] == "@alice/spatial" |
| 173 | |
| 174 | |
| 175 | def test_post_json_raises_on_non_object_response() -> None: |
| 176 | """_post_json should raise ValueError when server returns a JSON array.""" |
| 177 | payload = _PublishPayload( |
| 178 | author_slug="bob", slug="s", display_name="S", description="d", |
| 179 | capabilities=_Capabilities(), viewer_type="v", version="0.1.0", |
| 180 | ) |
| 181 | mock_resp = _mock_urlopen(json.dumps(["not", "an", "object"])) |
| 182 | with unittest.mock.patch("urllib.request.urlopen", return_value=mock_resp): |
| 183 | with pytest.raises(ValueError, match="Expected JSON object"): |
| 184 | _post_json("https://hub.test/api/v1/domains", payload, _make_signing()) |
| 185 | |
| 186 | |
| 187 | def test_post_json_raises_http_error() -> None: |
| 188 | """_post_json should propagate HTTPError from urlopen.""" |
| 189 | payload = _PublishPayload( |
| 190 | author_slug="bob", slug="s", display_name="S", description="d", |
| 191 | capabilities=_Capabilities(), viewer_type="v", version="0.1.0", |
| 192 | ) |
| 193 | err = urllib.error.HTTPError( |
| 194 | url="https://hub.test/api/v1/domains", |
| 195 | code=409, |
| 196 | msg="Conflict", |
| 197 | hdrs=http.client.HTTPMessage(), |
| 198 | fp=io.BytesIO(b'{"error": "already_exists"}'), |
| 199 | ) |
| 200 | with unittest.mock.patch("urllib.request.urlopen", side_effect=err): |
| 201 | with pytest.raises(urllib.error.HTTPError): |
| 202 | _post_json("https://hub.test/api/v1/domains", payload, _make_signing()) |
| 203 | |
| 204 | |
| 205 | # --------------------------------------------------------------------------- |
| 206 | # CLI integration tests |
| 207 | # --------------------------------------------------------------------------- |
| 208 | |
| 209 | |
| 210 | def test_publish_success(repo: pathlib.Path) -> None: |
| 211 | """Successful publish prints domain scoped_id and manifest_hash.""" |
| 212 | server_resp = json.dumps({ |
| 213 | "domain_id": "dom-001", |
| 214 | "scoped_id": "@testuser/genomics", |
| 215 | "manifest_hash": "sha256:abc123", |
| 216 | }) |
| 217 | mock_resp = _mock_urlopen(server_resp) |
| 218 | |
| 219 | with unittest.mock.patch("urllib.request.urlopen", return_value=mock_resp): |
| 220 | result = runner.invoke(cli, _REQUIRED_ARGS) |
| 221 | |
| 222 | assert result.exit_code == 0, result.output |
| 223 | assert "@testuser/genomics" in result.output |
| 224 | assert "sha256:abc123" in result.output |
| 225 | |
| 226 | |
| 227 | def test_publish_json_flag(repo: pathlib.Path) -> None: |
| 228 | """--json flag emits machine-readable JSON.""" |
| 229 | server_resp = json.dumps({ |
| 230 | "domain_id": "dom-002", |
| 231 | "scoped_id": "@testuser/genomics", |
| 232 | "manifest_hash": "sha256:def456", |
| 233 | }) |
| 234 | mock_resp = _mock_urlopen(server_resp) |
| 235 | |
| 236 | with unittest.mock.patch("urllib.request.urlopen", return_value=mock_resp): |
| 237 | result = runner.invoke(cli, _REQUIRED_ARGS + ["--json"]) |
| 238 | |
| 239 | assert result.exit_code == 0, result.output |
| 240 | data = json.loads(result.output.strip()) |
| 241 | assert data["scoped_id"] == "@testuser/genomics" |
| 242 | |
| 243 | |
| 244 | def test_publish_no_token(repo_no_token: pathlib.Path) -> None: |
| 245 | """Missing auth token should exit 1 with clear message.""" |
| 246 | result = runner.invoke(cli, _REQUIRED_ARGS) |
| 247 | assert result.exit_code != 0 |
| 248 | assert "token" in result.stderr.lower() or "auth" in result.stderr.lower() |
| 249 | |
| 250 | |
| 251 | def test_publish_http_409_conflict(repo: pathlib.Path) -> None: |
| 252 | """HTTP 409 should exit 1 with 'already registered' message.""" |
| 253 | err = urllib.error.HTTPError( |
| 254 | url="https://hub.test/api/v1/domains", |
| 255 | code=409, |
| 256 | msg="Conflict", |
| 257 | hdrs=http.client.HTTPMessage(), |
| 258 | fp=io.BytesIO(b'{"error": "already_exists"}'), |
| 259 | ) |
| 260 | with unittest.mock.patch("urllib.request.urlopen", side_effect=err): |
| 261 | result = runner.invoke(cli, _REQUIRED_ARGS) |
| 262 | |
| 263 | assert result.exit_code != 0 |
| 264 | assert "already registered" in result.stderr.lower() or "409" in result.stderr |
| 265 | |
| 266 | |
| 267 | def test_publish_http_401_unauthorized(repo: pathlib.Path) -> None: |
| 268 | """HTTP 401 should exit 1 with authentication message.""" |
| 269 | err = urllib.error.HTTPError( |
| 270 | url="https://hub.test/api/v1/domains", |
| 271 | code=401, |
| 272 | msg="Unauthorized", |
| 273 | hdrs=http.client.HTTPMessage(), |
| 274 | fp=io.BytesIO(b'{"error": "unauthorized"}'), |
| 275 | ) |
| 276 | with unittest.mock.patch("urllib.request.urlopen", side_effect=err): |
| 277 | result = runner.invoke(cli, _REQUIRED_ARGS) |
| 278 | |
| 279 | assert result.exit_code != 0 |
| 280 | assert "authentication" in result.stderr.lower() or "token" in result.stderr.lower() |
| 281 | |
| 282 | |
| 283 | def test_publish_network_error(repo: pathlib.Path) -> None: |
| 284 | """URLError (network failure) should exit 1 with 'Could not reach' message.""" |
| 285 | err = urllib.error.URLError(reason="Connection refused") |
| 286 | with unittest.mock.patch("urllib.request.urlopen", side_effect=err): |
| 287 | result = runner.invoke(cli, _REQUIRED_ARGS) |
| 288 | |
| 289 | assert result.exit_code != 0 |
| 290 | assert "could not reach" in result.stderr.lower() |
| 291 | |
| 292 | |
| 293 | def test_publish_bad_json_response(repo: pathlib.Path) -> None: |
| 294 | """Non-JSON server response should exit 1 with 'Unexpected response' message.""" |
| 295 | mock_resp = _mock_urlopen(b"not json at all") |
| 296 | with unittest.mock.patch("urllib.request.urlopen", return_value=mock_resp): |
| 297 | result = runner.invoke(cli, _REQUIRED_ARGS) |
| 298 | |
| 299 | assert result.exit_code != 0 |
| 300 | assert "unexpected" in result.stderr.lower() |
| 301 | |
| 302 | |
| 303 | def test_publish_missing_required_author(repo: pathlib.Path) -> None: |
| 304 | """Omitting --author should produce a non-zero exit and usage message.""" |
| 305 | args = [a for a in _REQUIRED_ARGS if a != "--author" and a != "testuser"] |
| 306 | result = runner.invoke(cli, args) |
| 307 | assert result.exit_code != 0 |
| 308 | |
| 309 | |
| 310 | def test_publish_missing_required_slug(repo: pathlib.Path) -> None: |
| 311 | """Omitting --slug should produce a non-zero exit.""" |
| 312 | args = [a for a in _REQUIRED_ARGS if a != "--slug" and a != "genomics"] |
| 313 | result = runner.invoke(cli, args) |
| 314 | assert result.exit_code != 0 |
| 315 | |
| 316 | |
| 317 | def test_publish_capabilities_from_plugin_schema(repo: pathlib.Path) -> None: |
| 318 | """When --capabilities is omitted, schema is derived from active domain plugin.""" |
| 319 | # Remove --capabilities from args |
| 320 | args_no_caps = [ |
| 321 | a for i, a in enumerate(_REQUIRED_ARGS) |
| 322 | if a not in ("--capabilities", _BASE_CAPS_JSON) |
| 323 | and not (i > 0 and _REQUIRED_ARGS[i - 1] == "--capabilities") |
| 324 | ] |
| 325 | |
| 326 | server_resp = json.dumps({ |
| 327 | "domain_id": "dom-plugin", |
| 328 | "scoped_id": "@testuser/genomics", |
| 329 | "manifest_hash": "sha256:plugin", |
| 330 | }) |
| 331 | mock_resp = _mock_urlopen(server_resp) |
| 332 | |
| 333 | with unittest.mock.patch("urllib.request.urlopen", return_value=mock_resp): |
| 334 | result = runner.invoke(cli, args_no_caps) |
| 335 | |
| 336 | # Should succeed (midi plugin schema is available) |
| 337 | assert result.exit_code == 0, result.output |
| 338 | assert "@testuser/genomics" in result.output |
| 339 | |
| 340 | |
| 341 | def test_publish_invalid_capabilities_json(repo: pathlib.Path) -> None: |
| 342 | """--capabilities with invalid JSON should exit 1.""" |
| 343 | bad_caps_args = [ |
| 344 | "domains", "publish", |
| 345 | "--author", "testuser", |
| 346 | "--slug", "genomics", |
| 347 | "--name", "Genomics", |
| 348 | "--description", "Version DNA sequences", |
| 349 | "--viewer-type", "genome", |
| 350 | "--capabilities", "{not valid json", |
| 351 | "--hub", "https://hub.test", |
| 352 | ] |
| 353 | result = runner.invoke(cli, bad_caps_args) |
| 354 | assert result.exit_code != 0 |
| 355 | assert "json" in result.stderr.lower() |
| 356 | |
| 357 | |
| 358 | def test_publish_http_500_server_error(repo: pathlib.Path) -> None: |
| 359 | """HTTP 5xx should exit 1 with HTTP error code shown.""" |
| 360 | err = urllib.error.HTTPError( |
| 361 | url="https://hub.test/api/v1/domains", |
| 362 | code=500, |
| 363 | msg="Internal Server Error", |
| 364 | hdrs=http.client.HTTPMessage(), |
| 365 | fp=io.BytesIO(b'{"error": "server_error"}'), |
| 366 | ) |
| 367 | with unittest.mock.patch("urllib.request.urlopen", side_effect=err): |
| 368 | result = runner.invoke(cli, _REQUIRED_ARGS) |
| 369 | |
| 370 | assert result.exit_code != 0 |
| 371 | assert "500" in result.stderr |
| 372 | |
| 373 | |
| 374 | def test_publish_custom_version(repo: pathlib.Path) -> None: |
| 375 | """--version flag is passed to the server payload.""" |
| 376 | captured_bodies: list[bytes] = [] |
| 377 | |
| 378 | def _capture(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock: |
| 379 | raw = req.data |
| 380 | captured_bodies.append(raw if raw is not None else b"") |
| 381 | return _mock_urlopen(json.dumps({"domain_id": "d", "scoped_id": "@testuser/genomics", "manifest_hash": "h"})) |
| 382 | |
| 383 | with unittest.mock.patch("urllib.request.urlopen", side_effect=_capture): |
| 384 | result = runner.invoke(cli, _REQUIRED_ARGS + ["--version", "1.2.3"]) |
| 385 | |
| 386 | assert result.exit_code == 0, result.output |
| 387 | body = json.loads(captured_bodies[0]) |
| 388 | assert body["version"] == "1.2.3" |
File History
7 commits
sha256:18b983389ee1b55900fcd799bfbb496552d2e3ecded9d18cefbfef188947a12e
chore: remove blob-debug test marker file
Sonnet 4.6
1 day ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8
chore: prebuild timing test
Sonnet 4.6
8 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1
merge: pull staging/dev — advance to 0.2.0rc12
Sonnet 4.6
patch
10 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea
fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub …
Sonnet 4.6
21 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e
fix: rename objects→blobs in push client and all stale test…
Sonnet 4.6
patch
24 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a
fix: repair four test failures from post-migration audit
Sonnet 4.6
patch
31 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf
fix: unified object store migration — idempotent writes, JS…
Sonnet 4.6
minor
⚠
31 days ago