gabriel / muse public
test_domains_publish.py python
387 lines 14.6 KB
Raw
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