gabriel / muse public

test_domains_publish.py file-level

at sha256:6 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:6 chore(timeline): remove unused RationalRate import in entity.py · · Jul 10, 2026
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, context: object = 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_targets_api_domains_not_v1(repo: pathlib.Path) -> None:
375 """musehub#117 DOM_01: run_publish must POST to /api/domains.
376
377 /api/v1/domains does not exist anywhere on the server — no /api/v1/*
378 namespace is mounted at all. The real, tested, working route is
379 /api/domains (musehub/api/routes/musehub/domains.py, mounted with
380 prefix="/api" in musehub/main.py). Every request against the old
381 endpoint 404s before ever reaching the publish logic.
382 """
383 server_resp = json.dumps({
384 "domain_id": "dom-003",
385 "scoped_id": "@testuser/genomics",
386 "manifest_hash": "sha256:abc123",
387 })
388 mock_resp = _mock_urlopen(server_resp)
389 captured_requests: list[urllib.request.Request] = []
390
391 def _capture_urlopen(req: urllib.request.Request, *a: object, **kw: object) -> unittest.mock.MagicMock:
392 captured_requests.append(req)
393 return mock_resp
394
395 with unittest.mock.patch("urllib.request.urlopen", side_effect=_capture_urlopen):
396 result = runner.invoke(cli, _REQUIRED_ARGS)
397
398 assert result.exit_code == 0, result.output
399 assert len(captured_requests) == 1
400 assert captured_requests[0].full_url == "https://hub.test/api/domains"
401
402
403 def test_publish_custom_version(repo: pathlib.Path) -> None:
404 """--version flag is passed to the server payload."""
405 captured_bodies: list[bytes] = []
406
407 def _capture(req: urllib.request.Request, timeout: float | None = None, context: object = None) -> unittest.mock.MagicMock:
408 raw = req.data
409 captured_bodies.append(raw if raw is not None else b"")
410 return _mock_urlopen(json.dumps({"domain_id": "d", "scoped_id": "@testuser/genomics", "manifest_hash": "h"}))
411
412 with unittest.mock.patch("urllib.request.urlopen", side_effect=_capture):
413 result = runner.invoke(cli, _REQUIRED_ARGS + ["--version", "1.2.3"])
414
415 assert result.exit_code == 0, result.output
416 body = json.loads(captured_bodies[0])
417 assert body["version"] == "1.2.3"
418
419
420 # ===========================================================================
421 # musehub#117 DOM_15 — --dry-run validates the manifest locally, no network
422 # call, no auth required.
423 # ===========================================================================
424
425
426 def test_dry_run_valid_manifest_exits_zero(repo: pathlib.Path) -> None:
427 """--dry-run with a valid manifest reports valid, makes no network call."""
428 with unittest.mock.patch("urllib.request.urlopen") as mock_urlopen:
429 result = runner.invoke(
430 cli,
431 [
432 "domains", "publish",
433 "--author", "testuser", "--slug", "genomics",
434 "--name", "Genomics", "--description", "Version DNA sequences",
435 "--viewer-type", "piano_roll",
436 "--capabilities", _BASE_CAPS_JSON,
437 "--dry-run", "--json",
438 ],
439 )
440 assert result.exit_code == 0, result.output
441 mock_urlopen.assert_not_called()
442 data = json.loads(result.output)
443 assert data["valid"] is True
444 assert data["errors"] == []
445
446
447 def test_dry_run_invalid_viewer_type_exits_one(repo: pathlib.Path) -> None:
448 """--dry-run catches an invalid viewer_type before any network call.
449
450 _REQUIRED_ARGS uses --viewer-type genome — not one of the server's real
451 palette values (generic/symbol_graph/piano_roll) — as its default fixture
452 value, which doubles as a ready-made invalid case here.
453 """
454 with unittest.mock.patch("urllib.request.urlopen") as mock_urlopen:
455 result = runner.invoke(cli, _REQUIRED_ARGS + ["--dry-run", "--json"])
456 assert result.exit_code == 1
457 mock_urlopen.assert_not_called()
458 data = json.loads(result.output)
459 assert data["valid"] is False
460 assert any("viewer_type" in err for err in data["errors"])
461
462
463 def test_dry_run_invalid_merge_semantics_exits_one(repo: pathlib.Path) -> None:
464 """--dry-run catches an invalid merge_semantics value."""
465 bad_caps = json.dumps({"merge_semantics": "yolo"})
466 with unittest.mock.patch("urllib.request.urlopen") as mock_urlopen:
467 result = runner.invoke(
468 cli,
469 [
470 "domains", "publish",
471 "--author", "testuser", "--slug", "genomics",
472 "--name", "Genomics", "--description", "Version DNA sequences",
473 "--viewer-type", "generic",
474 "--capabilities", bad_caps,
475 "--dry-run", "--json",
476 ],
477 )
478 assert result.exit_code == 1
479 mock_urlopen.assert_not_called()
480 data = json.loads(result.output)
481 assert data["valid"] is False
482 assert any("merge_semantics" in err for err in data["errors"])
483
484
485 def test_dry_run_too_many_dimensions_exits_one(repo: pathlib.Path) -> None:
486 """--dry-run catches a dimensions list over the 50-entry cap."""
487 caps = json.dumps({"dimensions": [{"name": f"d{i}"} for i in range(51)]})
488 with unittest.mock.patch("urllib.request.urlopen") as mock_urlopen:
489 result = runner.invoke(
490 cli,
491 [
492 "domains", "publish",
493 "--author", "testuser", "--slug", "genomics",
494 "--name", "Genomics", "--description", "Version DNA sequences",
495 "--viewer-type", "generic",
496 "--capabilities", caps,
497 "--dry-run", "--json",
498 ],
499 )
500 assert result.exit_code == 1
501 mock_urlopen.assert_not_called()
502 data = json.loads(result.output)
503 assert data["valid"] is False
504 assert any("dimensions" in err for err in data["errors"])
505
506
507 def test_dry_run_requires_no_signing_identity(repo_no_token: pathlib.Path) -> None:
508 """--dry-run works even when no signing identity is configured — it
509 never needs to authenticate since it never hits the network."""
510 with unittest.mock.patch("urllib.request.urlopen") as mock_urlopen:
511 result = runner.invoke(
512 cli,
513 [
514 "domains", "publish",
515 "--author", "testuser", "--slug", "genomics",
516 "--name", "Genomics", "--description", "Version DNA sequences",
517 "--viewer-type", "piano_roll",
518 "--capabilities", _BASE_CAPS_JSON,
519 "--dry-run", "--json",
520 ],
521 )
522 assert result.exit_code == 0, result.output
523 mock_urlopen.assert_not_called()
524 data = json.loads(result.output)
525 assert data["valid"] is True
526
527
528 # ===========================================================================
529 # musehub#117 Phase 5 (DOM_16, DOM_17, DOM_18) — full CRUD parity: CLI
530 # equivalents of the server's new PATCH/DELETE routes.
531 # ===========================================================================
532
533 _UPDATE_REQUIRED_ARGS = [
534 "domains", "update",
535 "--author", "testuser",
536 "--slug", "genomics",
537 "--description", "Updated description",
538 "--hub", "https://hub.test",
539 ]
540
541 _DELETE_REQUIRED_ARGS = [
542 "domains", "delete",
543 "--author", "testuser",
544 "--slug", "genomics",
545 "--hub", "https://hub.test",
546 ]
547
548
549 def test_update_success(repo: pathlib.Path) -> None:
550 """Successful update prints the scoped_id."""
551 server_resp = json.dumps({
552 "scoped_id": "@testuser/genomics",
553 "display_name": "Genomics",
554 "manifest_hash": "sha256:abc123",
555 })
556 mock_resp = _mock_urlopen(server_resp)
557
558 with unittest.mock.patch("urllib.request.urlopen", return_value=mock_resp) as mock_urlopen:
559 result = runner.invoke(cli, _UPDATE_REQUIRED_ARGS)
560
561 assert result.exit_code == 0, result.output
562 assert "@testuser/genomics" in result.output
563 # PATCH, not POST — this is an update, not a create.
564 sent_req = mock_urlopen.call_args[0][0]
565 assert sent_req.get_method() == "PATCH"
566 assert sent_req.full_url == "https://hub.test/api/domains/@testuser/genomics"
567
568
569 def test_update_json_flag(repo: pathlib.Path) -> None:
570 """--json flag emits machine-readable JSON."""
571 server_resp = json.dumps({
572 "scoped_id": "@testuser/genomics",
573 "display_name": "Genomics",
574 "manifest_hash": "sha256:def456",
575 })
576 mock_resp = _mock_urlopen(server_resp)
577
578 with unittest.mock.patch("urllib.request.urlopen", return_value=mock_resp):
579 result = runner.invoke(cli, _UPDATE_REQUIRED_ARGS + ["--json"])
580
581 assert result.exit_code == 0, result.output
582 data = json.loads(result.output.strip())
583 assert data["scoped_id"] == "@testuser/genomics"
584 assert data["manifest_hash"] == "sha256:def456"
585
586
587 def test_update_no_fields_exits_one(repo: pathlib.Path) -> None:
588 """Omitting every updatable field should exit 1 before any network call."""
589 with unittest.mock.patch("urllib.request.urlopen") as mock_urlopen:
590 result = runner.invoke(
591 cli,
592 ["domains", "update", "--author", "testuser", "--slug", "genomics", "--hub", "https://hub.test"],
593 )
594 assert result.exit_code != 0
595 mock_urlopen.assert_not_called()
596 assert "at least one" in result.stderr.lower()
597
598
599 def test_update_no_token(repo_no_token: pathlib.Path) -> None:
600 """Missing auth token should exit 1 with a clear message."""
601 result = runner.invoke(cli, _UPDATE_REQUIRED_ARGS)
602 assert result.exit_code != 0
603 assert "token" in result.stderr.lower() or "auth" in result.stderr.lower()
604
605
606 def test_update_http_403_not_owner(repo: pathlib.Path) -> None:
607 """HTTP 403 should exit 1 with an ownership message."""
608 err = urllib.error.HTTPError(
609 url="https://hub.test/api/domains/@testuser/genomics",
610 code=403,
611 msg="Forbidden",
612 hdrs=http.client.HTTPMessage(),
613 fp=io.BytesIO(b'{"detail": "not the owner"}'),
614 )
615 with unittest.mock.patch("urllib.request.urlopen", side_effect=err):
616 result = runner.invoke(cli, _UPDATE_REQUIRED_ARGS)
617
618 assert result.exit_code != 0
619 assert "own" in result.stderr.lower()
620
621
622 def test_update_http_404_not_found(repo: pathlib.Path) -> None:
623 """HTTP 404 should exit 1 with a not-found message."""
624 err = urllib.error.HTTPError(
625 url="https://hub.test/api/domains/@testuser/genomics",
626 code=404,
627 msg="Not Found",
628 hdrs=http.client.HTTPMessage(),
629 fp=io.BytesIO(b'{"detail": "not found"}'),
630 )
631 with unittest.mock.patch("urllib.request.urlopen", side_effect=err):
632 result = runner.invoke(cli, _UPDATE_REQUIRED_ARGS)
633
634 assert result.exit_code != 0
635 assert "not found" in result.stderr.lower()
636
637
638 def test_update_http_422_validation(repo: pathlib.Path) -> None:
639 """HTTP 422 should exit 1 with the server's rejection message."""
640 err = urllib.error.HTTPError(
641 url="https://hub.test/api/domains/@testuser/genomics",
642 code=422,
643 msg="Unprocessable Entity",
644 hdrs=http.client.HTTPMessage(),
645 fp=io.BytesIO(b'{"detail": "bad viewer_type"}'),
646 )
647 with unittest.mock.patch("urllib.request.urlopen", side_effect=err):
648 result = runner.invoke(cli, _UPDATE_REQUIRED_ARGS)
649
650 assert result.exit_code != 0
651 assert "rejected" in result.stderr.lower()
652
653
654 def test_update_dry_run_valid(repo: pathlib.Path) -> None:
655 """--dry-run validates locally with no network call."""
656 with unittest.mock.patch("urllib.request.urlopen") as mock_urlopen:
657 result = runner.invoke(cli, _UPDATE_REQUIRED_ARGS + ["--dry-run", "--json"])
658 assert result.exit_code == 0, result.output
659 mock_urlopen.assert_not_called()
660 data = json.loads(result.output)
661 assert data["valid"] is True
662
663
664 def test_update_dry_run_invalid_viewer_type(repo: pathlib.Path) -> None:
665 """--dry-run catches an invalid --viewer-type before any network call."""
666 with unittest.mock.patch("urllib.request.urlopen") as mock_urlopen:
667 result = runner.invoke(
668 cli,
669 [
670 "domains", "update",
671 "--author", "testuser", "--slug", "genomics",
672 "--viewer-type", "bogus",
673 "--hub", "https://hub.test",
674 "--dry-run", "--json",
675 ],
676 )
677 assert result.exit_code == 1
678 mock_urlopen.assert_not_called()
679 data = json.loads(result.output)
680 assert data["valid"] is False
681 assert any("viewer_type" in err for err in data["errors"])
682
683
684 def test_delete_success(repo: pathlib.Path) -> None:
685 """Successful delete prints a deprecation confirmation."""
686 mock_resp = _mock_urlopen(b"")
687
688 with unittest.mock.patch("urllib.request.urlopen", return_value=mock_resp) as mock_urlopen:
689 result = runner.invoke(cli, _DELETE_REQUIRED_ARGS)
690
691 assert result.exit_code == 0, result.output
692 assert "@testuser/genomics" in result.output
693 sent_req = mock_urlopen.call_args[0][0]
694 assert sent_req.get_method() == "DELETE"
695 assert sent_req.full_url == "https://hub.test/api/domains/@testuser/genomics"
696
697
698 def test_delete_json_flag(repo: pathlib.Path) -> None:
699 """--json flag emits machine-readable JSON with status: deprecated."""
700 mock_resp = _mock_urlopen(b"")
701
702 with unittest.mock.patch("urllib.request.urlopen", return_value=mock_resp):
703 result = runner.invoke(cli, _DELETE_REQUIRED_ARGS + ["--json"])
704
705 assert result.exit_code == 0, result.output
706 data = json.loads(result.output.strip())
707 assert data["scoped_id"] == "@testuser/genomics"
708 assert data["status"] == "deprecated"
709
710
711 def test_delete_no_token(repo_no_token: pathlib.Path) -> None:
712 """Missing auth token should exit 1 with a clear message."""
713 result = runner.invoke(cli, _DELETE_REQUIRED_ARGS)
714 assert result.exit_code != 0
715 assert "token" in result.stderr.lower() or "auth" in result.stderr.lower()
716
717
718 def test_delete_http_403_not_owner(repo: pathlib.Path) -> None:
719 """HTTP 403 should exit 1 with an ownership message."""
720 err = urllib.error.HTTPError(
721 url="https://hub.test/api/domains/@testuser/genomics",
722 code=403,
723 msg="Forbidden",
724 hdrs=http.client.HTTPMessage(),
725 fp=io.BytesIO(b'{"detail": "not the owner"}'),
726 )
727 with unittest.mock.patch("urllib.request.urlopen", side_effect=err):
728 result = runner.invoke(cli, _DELETE_REQUIRED_ARGS)
729
730 assert result.exit_code != 0
731 assert "own" in result.stderr.lower()
732
733
734 def test_delete_http_404_not_found_or_already_deprecated(repo: pathlib.Path) -> None:
735 """HTTP 404 should exit 1 with a not-found/already-deprecated message."""
736 err = urllib.error.HTTPError(
737 url="https://hub.test/api/domains/@testuser/genomics",
738 code=404,
739 msg="Not Found",
740 hdrs=http.client.HTTPMessage(),
741 fp=io.BytesIO(b'{"detail": "not found"}'),
742 )
743 with unittest.mock.patch("urllib.request.urlopen", side_effect=err):
744 result = runner.invoke(cli, _DELETE_REQUIRED_ARGS)
745
746 assert result.exit_code != 0
747 assert "not found" in result.stderr.lower()
748
749
750 def test_delete_network_error(repo: pathlib.Path) -> None:
751 """URLError (network failure) should exit 1 with a 'Could not reach' message."""
752 err = urllib.error.URLError(reason="Connection refused")
753 with unittest.mock.patch("urllib.request.urlopen", side_effect=err):
754 result = runner.invoke(cli, _DELETE_REQUIRED_ARGS)
755
756 assert result.exit_code != 0
757 assert "could not reach" in result.stderr.lower()
758
759
760 def test_delete_missing_required_author(repo: pathlib.Path) -> None:
761 """Omitting --author should produce a non-zero exit and usage message."""
762 args = [a for a in _DELETE_REQUIRED_ARGS if a not in ("--author", "testuser")]
763 result = runner.invoke(cli, args)
764 assert result.exit_code != 0