"""Tests for ``muse domains publish`` — the MuseHub marketplace publisher. Covers: Unit tests for ``_post_json`` helper: - Sends correct HTTP method, URL, headers, and JSON body. - Returns a typed _PublishResponse on 200. - Raises HTTPError on non-2xx. - Raises ValueError on non-object JSON. CLI integration tests (via Typer CliRunner, no real HTTP): - Successful publish with --capabilities JSON emits domain_id / scoped_id. - Successful publish with --json emits machine-readable JSON. - Missing required args → UsageError (non-zero exit). - No auth token → exit 1 with clear message. - HTTP 409 conflict → exit 1 with "already registered" message. - HTTP 401 → exit 1 with "Authentication failed" message. - Network error (URLError) → exit 1 with "Could not reach" message. - Non-JSON response body → exit 1 with "Unexpected response" message. - Capabilities derived from plugin schema when --capabilities is omitted. """ from __future__ import annotations import http.client import io import json import pathlib import urllib.error import urllib.request import urllib.response import unittest.mock from typing import TYPE_CHECKING import pytest from tests.cli_test_helper import CliRunner from muse._version import __version__ from muse.core.paths import muse_dir cli = None # argparse migration — CliRunner ignores this arg from muse.cli.commands.domains import _post_json, _PublishPayload, _Capabilities, _DimensionDef if TYPE_CHECKING: pass runner = CliRunner() def _make_signing() -> "SigningIdentity": """Generate a fresh Ed25519 SigningIdentity for tests.""" from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from muse.core.transport import SigningIdentity return SigningIdentity(handle="testuser", private_key=Ed25519PrivateKey.generate()) # --------------------------------------------------------------------------- # Fixture: minimal Muse repo with auth token # --------------------------------------------------------------------------- _BASE_CAPS_JSON = json.dumps({ "dimensions": [{"name": "notes", "description": "Note events"}], "artifact_types": ["mid"], "merge_semantics": "three_way", "supported_commands": ["commit", "diff"], }) _REQUIRED_ARGS = [ "domains", "publish", "--author", "testuser", "--slug", "genomics", "--name", "Genomics", "--description", "Version DNA sequences", "--viewer-type", "genome", "--capabilities", _BASE_CAPS_JSON, "--hub", "https://hub.test", ] @pytest.fixture() def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: """Minimal .muse/ repo; auth token is mocked via get_signing_identity.""" dot_muse = muse_dir(tmp_path) (dot_muse / "refs" / "heads").mkdir(parents=True) (dot_muse / "objects").mkdir() (dot_muse / "commits").mkdir() (dot_muse / "snapshots").mkdir() (dot_muse / "repo.json").write_text( json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"}) ) (dot_muse / "HEAD").write_text("ref: refs/heads/main\n") monkeypatch.chdir(tmp_path) monkeypatch.setattr("muse.cli.commands.domains.get_signing_identity", lambda *a, **kw: _make_signing()) return tmp_path @pytest.fixture() def repo_no_token(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: """Minimal .muse/ repo where get_signing_identity returns None (no token).""" dot_muse = muse_dir(tmp_path) (dot_muse / "refs" / "heads").mkdir(parents=True) (dot_muse / "objects").mkdir() (dot_muse / "commits").mkdir() (dot_muse / "snapshots").mkdir() (dot_muse / "repo.json").write_text( json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"}) ) (dot_muse / "HEAD").write_text("ref: refs/heads/main\n") monkeypatch.chdir(tmp_path) monkeypatch.setattr("muse.cli.commands.domains.get_signing_identity", lambda *a, **kw: None) return tmp_path # --------------------------------------------------------------------------- # Helper: build a mock urllib response # --------------------------------------------------------------------------- def _mock_urlopen(response_body: str | bytes, status: int = 200) -> unittest.mock.MagicMock: """Return a context-manager mock that yields a fake HTTP response.""" if isinstance(response_body, str): response_body = response_body.encode() mock_resp = unittest.mock.MagicMock() mock_resp.read.return_value = response_body mock_resp.__enter__ = unittest.mock.MagicMock(return_value=mock_resp) mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False) return mock_resp # --------------------------------------------------------------------------- # Unit tests for _post_json # --------------------------------------------------------------------------- def test_post_json_sends_correct_request() -> None: """_post_json should POST JSON to the given URL with Auth header.""" payload = _PublishPayload( author_slug="alice", slug="spatial", display_name="Spatial 3D", description="Version 3D scenes", capabilities=_Capabilities( dimensions=[_DimensionDef(name="geometry", description="Mesh data")], artifact_types=["glb"], merge_semantics="three_way", supported_commands=["commit"], ), viewer_type="spatial", version="0.1.0", ) captured: list[urllib.request.Request] = [] def _fake_urlopen( req: urllib.request.Request, timeout: float | None = None, context: object = None ) -> unittest.mock.MagicMock: captured.append(req) mock_resp = _mock_urlopen(json.dumps({"domain_id": "d1", "scoped_id": "@alice/spatial", "manifest_hash": "abc"})) return mock_resp with unittest.mock.patch("urllib.request.urlopen", side_effect=_fake_urlopen): result = _post_json("https://hub.test/api/v1/domains", payload, _make_signing()) assert len(captured) == 1 req = captured[0] assert req.get_method() == "POST" assert req.full_url == "https://hub.test/api/v1/domains" assert req.get_header("Authorization").startswith("MSign handle=\"testuser\"") assert req.get_header("Content-type") == "application/json" assert req.data is not None body = json.loads(req.data.decode()) assert body["author_slug"] == "alice" assert body["slug"] == "spatial" assert result["scoped_id"] == "@alice/spatial" def test_post_json_raises_on_non_object_response() -> None: """_post_json should raise ValueError when server returns a JSON array.""" payload = _PublishPayload( author_slug="bob", slug="s", display_name="S", description="d", capabilities=_Capabilities(), viewer_type="v", version="0.1.0", ) mock_resp = _mock_urlopen(json.dumps(["not", "an", "object"])) with unittest.mock.patch("urllib.request.urlopen", return_value=mock_resp): with pytest.raises(ValueError, match="Expected JSON object"): _post_json("https://hub.test/api/v1/domains", payload, _make_signing()) def test_post_json_raises_http_error() -> None: """_post_json should propagate HTTPError from urlopen.""" payload = _PublishPayload( author_slug="bob", slug="s", display_name="S", description="d", capabilities=_Capabilities(), viewer_type="v", version="0.1.0", ) err = urllib.error.HTTPError( url="https://hub.test/api/v1/domains", code=409, msg="Conflict", hdrs=http.client.HTTPMessage(), fp=io.BytesIO(b'{"error": "already_exists"}'), ) with unittest.mock.patch("urllib.request.urlopen", side_effect=err): with pytest.raises(urllib.error.HTTPError): _post_json("https://hub.test/api/v1/domains", payload, _make_signing()) # --------------------------------------------------------------------------- # CLI integration tests # --------------------------------------------------------------------------- def test_publish_success(repo: pathlib.Path) -> None: """Successful publish prints domain scoped_id and manifest_hash.""" server_resp = json.dumps({ "domain_id": "dom-001", "scoped_id": "@testuser/genomics", "manifest_hash": "sha256:abc123", }) mock_resp = _mock_urlopen(server_resp) with unittest.mock.patch("urllib.request.urlopen", return_value=mock_resp): result = runner.invoke(cli, _REQUIRED_ARGS) assert result.exit_code == 0, result.output assert "@testuser/genomics" in result.output assert "sha256:abc123" in result.output def test_publish_json_flag(repo: pathlib.Path) -> None: """--json flag emits machine-readable JSON.""" server_resp = json.dumps({ "domain_id": "dom-002", "scoped_id": "@testuser/genomics", "manifest_hash": "sha256:def456", }) mock_resp = _mock_urlopen(server_resp) with unittest.mock.patch("urllib.request.urlopen", return_value=mock_resp): result = runner.invoke(cli, _REQUIRED_ARGS + ["--json"]) assert result.exit_code == 0, result.output data = json.loads(result.output.strip()) assert data["scoped_id"] == "@testuser/genomics" def test_publish_no_token(repo_no_token: pathlib.Path) -> None: """Missing auth token should exit 1 with clear message.""" result = runner.invoke(cli, _REQUIRED_ARGS) assert result.exit_code != 0 assert "token" in result.stderr.lower() or "auth" in result.stderr.lower() def test_publish_http_409_conflict(repo: pathlib.Path) -> None: """HTTP 409 should exit 1 with 'already registered' message.""" err = urllib.error.HTTPError( url="https://hub.test/api/v1/domains", code=409, msg="Conflict", hdrs=http.client.HTTPMessage(), fp=io.BytesIO(b'{"error": "already_exists"}'), ) with unittest.mock.patch("urllib.request.urlopen", side_effect=err): result = runner.invoke(cli, _REQUIRED_ARGS) assert result.exit_code != 0 assert "already registered" in result.stderr.lower() or "409" in result.stderr def test_publish_http_401_unauthorized(repo: pathlib.Path) -> None: """HTTP 401 should exit 1 with authentication message.""" err = urllib.error.HTTPError( url="https://hub.test/api/v1/domains", code=401, msg="Unauthorized", hdrs=http.client.HTTPMessage(), fp=io.BytesIO(b'{"error": "unauthorized"}'), ) with unittest.mock.patch("urllib.request.urlopen", side_effect=err): result = runner.invoke(cli, _REQUIRED_ARGS) assert result.exit_code != 0 assert "authentication" in result.stderr.lower() or "token" in result.stderr.lower() def test_publish_network_error(repo: pathlib.Path) -> None: """URLError (network failure) should exit 1 with 'Could not reach' message.""" err = urllib.error.URLError(reason="Connection refused") with unittest.mock.patch("urllib.request.urlopen", side_effect=err): result = runner.invoke(cli, _REQUIRED_ARGS) assert result.exit_code != 0 assert "could not reach" in result.stderr.lower() def test_publish_bad_json_response(repo: pathlib.Path) -> None: """Non-JSON server response should exit 1 with 'Unexpected response' message.""" mock_resp = _mock_urlopen(b"not json at all") with unittest.mock.patch("urllib.request.urlopen", return_value=mock_resp): result = runner.invoke(cli, _REQUIRED_ARGS) assert result.exit_code != 0 assert "unexpected" in result.stderr.lower() def test_publish_missing_required_author(repo: pathlib.Path) -> None: """Omitting --author should produce a non-zero exit and usage message.""" args = [a for a in _REQUIRED_ARGS if a != "--author" and a != "testuser"] result = runner.invoke(cli, args) assert result.exit_code != 0 def test_publish_missing_required_slug(repo: pathlib.Path) -> None: """Omitting --slug should produce a non-zero exit.""" args = [a for a in _REQUIRED_ARGS if a != "--slug" and a != "genomics"] result = runner.invoke(cli, args) assert result.exit_code != 0 def test_publish_capabilities_from_plugin_schema(repo: pathlib.Path) -> None: """When --capabilities is omitted, schema is derived from active domain plugin.""" # Remove --capabilities from args args_no_caps = [ a for i, a in enumerate(_REQUIRED_ARGS) if a not in ("--capabilities", _BASE_CAPS_JSON) and not (i > 0 and _REQUIRED_ARGS[i - 1] == "--capabilities") ] server_resp = json.dumps({ "domain_id": "dom-plugin", "scoped_id": "@testuser/genomics", "manifest_hash": "sha256:plugin", }) mock_resp = _mock_urlopen(server_resp) with unittest.mock.patch("urllib.request.urlopen", return_value=mock_resp): result = runner.invoke(cli, args_no_caps) # Should succeed (midi plugin schema is available) assert result.exit_code == 0, result.output assert "@testuser/genomics" in result.output def test_publish_invalid_capabilities_json(repo: pathlib.Path) -> None: """--capabilities with invalid JSON should exit 1.""" bad_caps_args = [ "domains", "publish", "--author", "testuser", "--slug", "genomics", "--name", "Genomics", "--description", "Version DNA sequences", "--viewer-type", "genome", "--capabilities", "{not valid json", "--hub", "https://hub.test", ] result = runner.invoke(cli, bad_caps_args) assert result.exit_code != 0 assert "json" in result.stderr.lower() def test_publish_http_500_server_error(repo: pathlib.Path) -> None: """HTTP 5xx should exit 1 with HTTP error code shown.""" err = urllib.error.HTTPError( url="https://hub.test/api/v1/domains", code=500, msg="Internal Server Error", hdrs=http.client.HTTPMessage(), fp=io.BytesIO(b'{"error": "server_error"}'), ) with unittest.mock.patch("urllib.request.urlopen", side_effect=err): result = runner.invoke(cli, _REQUIRED_ARGS) assert result.exit_code != 0 assert "500" in result.stderr def test_publish_targets_api_domains_not_v1(repo: pathlib.Path) -> None: """musehub#117 DOM_01: run_publish must POST to /api/domains. /api/v1/domains does not exist anywhere on the server — no /api/v1/* namespace is mounted at all. The real, tested, working route is /api/domains (musehub/api/routes/musehub/domains.py, mounted with prefix="/api" in musehub/main.py). Every request against the old endpoint 404s before ever reaching the publish logic. """ server_resp = json.dumps({ "domain_id": "dom-003", "scoped_id": "@testuser/genomics", "manifest_hash": "sha256:abc123", }) mock_resp = _mock_urlopen(server_resp) captured_requests: list[urllib.request.Request] = [] def _capture_urlopen(req: urllib.request.Request, *a: object, **kw: object) -> unittest.mock.MagicMock: captured_requests.append(req) return mock_resp with unittest.mock.patch("urllib.request.urlopen", side_effect=_capture_urlopen): result = runner.invoke(cli, _REQUIRED_ARGS) assert result.exit_code == 0, result.output assert len(captured_requests) == 1 assert captured_requests[0].full_url == "https://hub.test/api/domains" def test_publish_custom_version(repo: pathlib.Path) -> None: """--version flag is passed to the server payload.""" captured_bodies: list[bytes] = [] def _capture(req: urllib.request.Request, timeout: float | None = None, context: object = None) -> unittest.mock.MagicMock: raw = req.data captured_bodies.append(raw if raw is not None else b"") return _mock_urlopen(json.dumps({"domain_id": "d", "scoped_id": "@testuser/genomics", "manifest_hash": "h"})) with unittest.mock.patch("urllib.request.urlopen", side_effect=_capture): result = runner.invoke(cli, _REQUIRED_ARGS + ["--version", "1.2.3"]) assert result.exit_code == 0, result.output body = json.loads(captured_bodies[0]) assert body["version"] == "1.2.3" # =========================================================================== # musehub#117 DOM_15 — --dry-run validates the manifest locally, no network # call, no auth required. # =========================================================================== def test_dry_run_valid_manifest_exits_zero(repo: pathlib.Path) -> None: """--dry-run with a valid manifest reports valid, makes no network call.""" with unittest.mock.patch("urllib.request.urlopen") as mock_urlopen: result = runner.invoke( cli, [ "domains", "publish", "--author", "testuser", "--slug", "genomics", "--name", "Genomics", "--description", "Version DNA sequences", "--viewer-type", "piano_roll", "--capabilities", _BASE_CAPS_JSON, "--dry-run", "--json", ], ) assert result.exit_code == 0, result.output mock_urlopen.assert_not_called() data = json.loads(result.output) assert data["valid"] is True assert data["errors"] == [] def test_dry_run_invalid_viewer_type_exits_one(repo: pathlib.Path) -> None: """--dry-run catches an invalid viewer_type before any network call. _REQUIRED_ARGS uses --viewer-type genome — not one of the server's real palette values (generic/symbol_graph/piano_roll) — as its default fixture value, which doubles as a ready-made invalid case here. """ with unittest.mock.patch("urllib.request.urlopen") as mock_urlopen: result = runner.invoke(cli, _REQUIRED_ARGS + ["--dry-run", "--json"]) assert result.exit_code == 1 mock_urlopen.assert_not_called() data = json.loads(result.output) assert data["valid"] is False assert any("viewer_type" in err for err in data["errors"]) def test_dry_run_invalid_merge_semantics_exits_one(repo: pathlib.Path) -> None: """--dry-run catches an invalid merge_semantics value.""" bad_caps = json.dumps({"merge_semantics": "yolo"}) with unittest.mock.patch("urllib.request.urlopen") as mock_urlopen: result = runner.invoke( cli, [ "domains", "publish", "--author", "testuser", "--slug", "genomics", "--name", "Genomics", "--description", "Version DNA sequences", "--viewer-type", "generic", "--capabilities", bad_caps, "--dry-run", "--json", ], ) assert result.exit_code == 1 mock_urlopen.assert_not_called() data = json.loads(result.output) assert data["valid"] is False assert any("merge_semantics" in err for err in data["errors"]) def test_dry_run_too_many_dimensions_exits_one(repo: pathlib.Path) -> None: """--dry-run catches a dimensions list over the 50-entry cap.""" caps = json.dumps({"dimensions": [{"name": f"d{i}"} for i in range(51)]}) with unittest.mock.patch("urllib.request.urlopen") as mock_urlopen: result = runner.invoke( cli, [ "domains", "publish", "--author", "testuser", "--slug", "genomics", "--name", "Genomics", "--description", "Version DNA sequences", "--viewer-type", "generic", "--capabilities", caps, "--dry-run", "--json", ], ) assert result.exit_code == 1 mock_urlopen.assert_not_called() data = json.loads(result.output) assert data["valid"] is False assert any("dimensions" in err for err in data["errors"]) def test_dry_run_requires_no_signing_identity(repo_no_token: pathlib.Path) -> None: """--dry-run works even when no signing identity is configured — it never needs to authenticate since it never hits the network.""" with unittest.mock.patch("urllib.request.urlopen") as mock_urlopen: result = runner.invoke( cli, [ "domains", "publish", "--author", "testuser", "--slug", "genomics", "--name", "Genomics", "--description", "Version DNA sequences", "--viewer-type", "piano_roll", "--capabilities", _BASE_CAPS_JSON, "--dry-run", "--json", ], ) assert result.exit_code == 0, result.output mock_urlopen.assert_not_called() data = json.loads(result.output) assert data["valid"] is True # =========================================================================== # musehub#117 Phase 5 (DOM_16, DOM_17, DOM_18) — full CRUD parity: CLI # equivalents of the server's new PATCH/DELETE routes. # =========================================================================== _UPDATE_REQUIRED_ARGS = [ "domains", "update", "--author", "testuser", "--slug", "genomics", "--description", "Updated description", "--hub", "https://hub.test", ] _DELETE_REQUIRED_ARGS = [ "domains", "delete", "--author", "testuser", "--slug", "genomics", "--hub", "https://hub.test", ] def test_update_success(repo: pathlib.Path) -> None: """Successful update prints the scoped_id.""" server_resp = json.dumps({ "scoped_id": "@testuser/genomics", "display_name": "Genomics", "manifest_hash": "sha256:abc123", }) mock_resp = _mock_urlopen(server_resp) with unittest.mock.patch("urllib.request.urlopen", return_value=mock_resp) as mock_urlopen: result = runner.invoke(cli, _UPDATE_REQUIRED_ARGS) assert result.exit_code == 0, result.output assert "@testuser/genomics" in result.output # PATCH, not POST — this is an update, not a create. sent_req = mock_urlopen.call_args[0][0] assert sent_req.get_method() == "PATCH" assert sent_req.full_url == "https://hub.test/api/domains/@testuser/genomics" def test_update_json_flag(repo: pathlib.Path) -> None: """--json flag emits machine-readable JSON.""" server_resp = json.dumps({ "scoped_id": "@testuser/genomics", "display_name": "Genomics", "manifest_hash": "sha256:def456", }) mock_resp = _mock_urlopen(server_resp) with unittest.mock.patch("urllib.request.urlopen", return_value=mock_resp): result = runner.invoke(cli, _UPDATE_REQUIRED_ARGS + ["--json"]) assert result.exit_code == 0, result.output data = json.loads(result.output.strip()) assert data["scoped_id"] == "@testuser/genomics" assert data["manifest_hash"] == "sha256:def456" def test_update_no_fields_exits_one(repo: pathlib.Path) -> None: """Omitting every updatable field should exit 1 before any network call.""" with unittest.mock.patch("urllib.request.urlopen") as mock_urlopen: result = runner.invoke( cli, ["domains", "update", "--author", "testuser", "--slug", "genomics", "--hub", "https://hub.test"], ) assert result.exit_code != 0 mock_urlopen.assert_not_called() assert "at least one" in result.stderr.lower() def test_update_no_token(repo_no_token: pathlib.Path) -> None: """Missing auth token should exit 1 with a clear message.""" result = runner.invoke(cli, _UPDATE_REQUIRED_ARGS) assert result.exit_code != 0 assert "token" in result.stderr.lower() or "auth" in result.stderr.lower() def test_update_http_403_not_owner(repo: pathlib.Path) -> None: """HTTP 403 should exit 1 with an ownership message.""" err = urllib.error.HTTPError( url="https://hub.test/api/domains/@testuser/genomics", code=403, msg="Forbidden", hdrs=http.client.HTTPMessage(), fp=io.BytesIO(b'{"detail": "not the owner"}'), ) with unittest.mock.patch("urllib.request.urlopen", side_effect=err): result = runner.invoke(cli, _UPDATE_REQUIRED_ARGS) assert result.exit_code != 0 assert "own" in result.stderr.lower() def test_update_http_404_not_found(repo: pathlib.Path) -> None: """HTTP 404 should exit 1 with a not-found message.""" err = urllib.error.HTTPError( url="https://hub.test/api/domains/@testuser/genomics", code=404, msg="Not Found", hdrs=http.client.HTTPMessage(), fp=io.BytesIO(b'{"detail": "not found"}'), ) with unittest.mock.patch("urllib.request.urlopen", side_effect=err): result = runner.invoke(cli, _UPDATE_REQUIRED_ARGS) assert result.exit_code != 0 assert "not found" in result.stderr.lower() def test_update_http_422_validation(repo: pathlib.Path) -> None: """HTTP 422 should exit 1 with the server's rejection message.""" err = urllib.error.HTTPError( url="https://hub.test/api/domains/@testuser/genomics", code=422, msg="Unprocessable Entity", hdrs=http.client.HTTPMessage(), fp=io.BytesIO(b'{"detail": "bad viewer_type"}'), ) with unittest.mock.patch("urllib.request.urlopen", side_effect=err): result = runner.invoke(cli, _UPDATE_REQUIRED_ARGS) assert result.exit_code != 0 assert "rejected" in result.stderr.lower() def test_update_dry_run_valid(repo: pathlib.Path) -> None: """--dry-run validates locally with no network call.""" with unittest.mock.patch("urllib.request.urlopen") as mock_urlopen: result = runner.invoke(cli, _UPDATE_REQUIRED_ARGS + ["--dry-run", "--json"]) assert result.exit_code == 0, result.output mock_urlopen.assert_not_called() data = json.loads(result.output) assert data["valid"] is True def test_update_dry_run_invalid_viewer_type(repo: pathlib.Path) -> None: """--dry-run catches an invalid --viewer-type before any network call.""" with unittest.mock.patch("urllib.request.urlopen") as mock_urlopen: result = runner.invoke( cli, [ "domains", "update", "--author", "testuser", "--slug", "genomics", "--viewer-type", "bogus", "--hub", "https://hub.test", "--dry-run", "--json", ], ) assert result.exit_code == 1 mock_urlopen.assert_not_called() data = json.loads(result.output) assert data["valid"] is False assert any("viewer_type" in err for err in data["errors"]) def test_delete_success(repo: pathlib.Path) -> None: """Successful delete prints a deprecation confirmation.""" mock_resp = _mock_urlopen(b"") with unittest.mock.patch("urllib.request.urlopen", return_value=mock_resp) as mock_urlopen: result = runner.invoke(cli, _DELETE_REQUIRED_ARGS) assert result.exit_code == 0, result.output assert "@testuser/genomics" in result.output sent_req = mock_urlopen.call_args[0][0] assert sent_req.get_method() == "DELETE" assert sent_req.full_url == "https://hub.test/api/domains/@testuser/genomics" def test_delete_json_flag(repo: pathlib.Path) -> None: """--json flag emits machine-readable JSON with status: deprecated.""" mock_resp = _mock_urlopen(b"") with unittest.mock.patch("urllib.request.urlopen", return_value=mock_resp): result = runner.invoke(cli, _DELETE_REQUIRED_ARGS + ["--json"]) assert result.exit_code == 0, result.output data = json.loads(result.output.strip()) assert data["scoped_id"] == "@testuser/genomics" assert data["status"] == "deprecated" def test_delete_no_token(repo_no_token: pathlib.Path) -> None: """Missing auth token should exit 1 with a clear message.""" result = runner.invoke(cli, _DELETE_REQUIRED_ARGS) assert result.exit_code != 0 assert "token" in result.stderr.lower() or "auth" in result.stderr.lower() def test_delete_http_403_not_owner(repo: pathlib.Path) -> None: """HTTP 403 should exit 1 with an ownership message.""" err = urllib.error.HTTPError( url="https://hub.test/api/domains/@testuser/genomics", code=403, msg="Forbidden", hdrs=http.client.HTTPMessage(), fp=io.BytesIO(b'{"detail": "not the owner"}'), ) with unittest.mock.patch("urllib.request.urlopen", side_effect=err): result = runner.invoke(cli, _DELETE_REQUIRED_ARGS) assert result.exit_code != 0 assert "own" in result.stderr.lower() def test_delete_http_404_not_found_or_already_deprecated(repo: pathlib.Path) -> None: """HTTP 404 should exit 1 with a not-found/already-deprecated message.""" err = urllib.error.HTTPError( url="https://hub.test/api/domains/@testuser/genomics", code=404, msg="Not Found", hdrs=http.client.HTTPMessage(), fp=io.BytesIO(b'{"detail": "not found"}'), ) with unittest.mock.patch("urllib.request.urlopen", side_effect=err): result = runner.invoke(cli, _DELETE_REQUIRED_ARGS) assert result.exit_code != 0 assert "not found" in result.stderr.lower() def test_delete_network_error(repo: pathlib.Path) -> None: """URLError (network failure) should exit 1 with a 'Could not reach' message.""" err = urllib.error.URLError(reason="Connection refused") with unittest.mock.patch("urllib.request.urlopen", side_effect=err): result = runner.invoke(cli, _DELETE_REQUIRED_ARGS) assert result.exit_code != 0 assert "could not reach" in result.stderr.lower() def test_delete_missing_required_author(repo: pathlib.Path) -> None: """Omitting --author should produce a non-zero exit and usage message.""" args = [a for a in _DELETE_REQUIRED_ARGS if a not in ("--author", "testuser")] result = runner.invoke(cli, args) assert result.exit_code != 0