gabriel / muse public
test_domains_publish.py python
388 lines 14.6 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 23 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 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 4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 23 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 30 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 31 days ago