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