test_stress_domains_publish.py
python
| 1 | """Stress and integration tests for ``muse domains publish``. |
| 2 | |
| 3 | Covers: |
| 4 | Stress: |
| 5 | - 500 sequential publish calls with mocked HTTP (throughput baseline) |
| 6 | - Concurrent mock publishes via threading (thread-safety of CliRunner) |
| 7 | - Large capabilities JSON (100 dimensions, 50 artifact types) |
| 8 | - Rapid successive 409 conflicts do not leak state |
| 9 | |
| 10 | Integration (end-to-end CLI): |
| 11 | - Full workflow: scaffold → register → publish (all mock layers wired) |
| 12 | - Round-trip: publish → parse --json → assert all fields present |
| 13 | - Author/slug with hyphens and underscores (URL-safe validation) |
| 14 | - Unicode description does not corrupt JSON encoding |
| 15 | - Very long description (4000 chars) is transmitted verbatim |
| 16 | - Hub URL override propagates to HTTP request |
| 17 | """ |
| 18 | from __future__ import annotations |
| 19 | |
| 20 | import concurrent.futures |
| 21 | import http.client |
| 22 | import io |
| 23 | import json |
| 24 | import pathlib |
| 25 | import threading |
| 26 | import time |
| 27 | import urllib.error |
| 28 | import urllib.request |
| 29 | import unittest.mock |
| 30 | from typing import Generator |
| 31 | |
| 32 | import pytest |
| 33 | from typer.testing import CliRunner |
| 34 | |
| 35 | from muse._version import __version__ |
| 36 | from muse.cli.app import cli |
| 37 | from muse.cli.commands.domains import _post_json, _PublishPayload, _Capabilities, _DimensionDef |
| 38 | |
| 39 | runner = CliRunner() |
| 40 | |
| 41 | # --------------------------------------------------------------------------- |
| 42 | # Fixtures |
| 43 | # --------------------------------------------------------------------------- |
| 44 | |
| 45 | _REQUIRED_ARGS = [ |
| 46 | "domains", "publish", |
| 47 | "--author", "alice", |
| 48 | "--slug", "genomics", |
| 49 | "--name", "Genomics", |
| 50 | "--description", "Version DNA sequences", |
| 51 | "--viewer-type", "genome", |
| 52 | "--capabilities", json.dumps({ |
| 53 | "dimensions": [{"name": "sequence", "description": "DNA base pairs"}], |
| 54 | "artifact_types": ["fasta"], |
| 55 | "merge_semantics": "three_way", |
| 56 | "supported_commands": ["commit", "diff"], |
| 57 | }), |
| 58 | "--hub", "https://hub.test", |
| 59 | ] |
| 60 | |
| 61 | _SUCCESS_BODY = json.dumps({ |
| 62 | "domain_id": "dom-001", |
| 63 | "scoped_id": "@alice/genomics", |
| 64 | "manifest_hash": "sha256:abc123", |
| 65 | }) |
| 66 | |
| 67 | |
| 68 | @pytest.fixture() |
| 69 | def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: |
| 70 | muse_dir = tmp_path / ".muse" |
| 71 | (muse_dir / "refs" / "heads").mkdir(parents=True) |
| 72 | (muse_dir / "objects").mkdir() |
| 73 | (muse_dir / "commits").mkdir() |
| 74 | (muse_dir / "snapshots").mkdir() |
| 75 | (muse_dir / "repo.json").write_text( |
| 76 | json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "midi"}) |
| 77 | ) |
| 78 | (muse_dir / "HEAD").write_text("ref: refs/heads/main\n") |
| 79 | monkeypatch.chdir(tmp_path) |
| 80 | monkeypatch.setattr("muse.cli.commands.domains.get_auth_token", lambda *a, **kw: "tok-test") |
| 81 | return tmp_path |
| 82 | |
| 83 | |
| 84 | def _mock_ok() -> unittest.mock.MagicMock: |
| 85 | """Return a context-manager mock that yields a 200 response.""" |
| 86 | mock_resp = unittest.mock.MagicMock() |
| 87 | mock_resp.read.return_value = _SUCCESS_BODY.encode() |
| 88 | mock_resp.__enter__ = unittest.mock.MagicMock(return_value=mock_resp) |
| 89 | mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False) |
| 90 | return mock_resp |
| 91 | |
| 92 | |
| 93 | # --------------------------------------------------------------------------- |
| 94 | # _post_json unit stress |
| 95 | # --------------------------------------------------------------------------- |
| 96 | |
| 97 | |
| 98 | def test_post_json_sequential_throughput() -> None: |
| 99 | """500 sequential _post_json calls complete in under 2 seconds (mock).""" |
| 100 | payload = _PublishPayload( |
| 101 | author_slug="alice", |
| 102 | slug="bench", |
| 103 | display_name="Bench", |
| 104 | description="Benchmark domain", |
| 105 | capabilities=_Capabilities( |
| 106 | dimensions=[_DimensionDef(name="x", description="x axis")], |
| 107 | merge_semantics="three_way", |
| 108 | ), |
| 109 | viewer_type="spatial", |
| 110 | version="0.1.0", |
| 111 | ) |
| 112 | |
| 113 | with unittest.mock.patch("urllib.request.urlopen", return_value=_mock_ok()): |
| 114 | start = time.monotonic() |
| 115 | for _ in range(500): |
| 116 | result = _post_json("https://hub.test/api/v1/domains", payload, "tok") |
| 117 | assert result["scoped_id"] == "@alice/genomics" |
| 118 | elapsed = time.monotonic() - start |
| 119 | |
| 120 | assert elapsed < 2.0, f"500 iterations took {elapsed:.2f}s — too slow" |
| 121 | |
| 122 | |
| 123 | def test_post_json_large_capabilities() -> None: |
| 124 | """_post_json handles 100-dimension, 50-artifact-type payloads without truncation.""" |
| 125 | dims = [_DimensionDef(name=f"dim_{i}", description=f"Dimension {i} " * 10) for i in range(100)] |
| 126 | artifacts = [f"type_{i:03}" for i in range(50)] |
| 127 | payload = _PublishPayload( |
| 128 | author_slug="alice", |
| 129 | slug="large", |
| 130 | display_name="Large Domain", |
| 131 | description="A domain with many dimensions", |
| 132 | capabilities=_Capabilities( |
| 133 | dimensions=dims, |
| 134 | artifact_types=artifacts, |
| 135 | merge_semantics="three_way", |
| 136 | supported_commands=["commit", "diff", "merge", "log", "status"], |
| 137 | ), |
| 138 | viewer_type="generic", |
| 139 | version="1.0.0", |
| 140 | ) |
| 141 | |
| 142 | captured_bodies: list[bytes] = [] |
| 143 | |
| 144 | def _capture(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock: |
| 145 | raw = req.data |
| 146 | captured_bodies.append(raw if raw is not None else b"") |
| 147 | return _mock_ok() |
| 148 | |
| 149 | with unittest.mock.patch("urllib.request.urlopen", side_effect=_capture): |
| 150 | result = _post_json("https://hub.test/api/v1/domains", payload, "tok") |
| 151 | |
| 152 | body = json.loads(captured_bodies[0]) |
| 153 | assert len(body["capabilities"]["dimensions"]) == 100 |
| 154 | assert len(body["capabilities"]["artifact_types"]) == 50 |
| 155 | assert result["scoped_id"] == "@alice/genomics" |
| 156 | |
| 157 | |
| 158 | def test_post_json_unicode_description() -> None: |
| 159 | """Unicode characters in description survive JSON round-trip correctly.""" |
| 160 | unicode_desc = "Version 🎵 séquences d'ADN — supports 漢字 and Ñoño input" |
| 161 | payload = _PublishPayload( |
| 162 | author_slug="alice", |
| 163 | slug="unicode", |
| 164 | display_name="Unicode Domain", |
| 165 | description=unicode_desc, |
| 166 | capabilities=_Capabilities(), |
| 167 | viewer_type="generic", |
| 168 | version="0.1.0", |
| 169 | ) |
| 170 | |
| 171 | captured_bodies: list[bytes] = [] |
| 172 | |
| 173 | def _capture(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock: |
| 174 | raw = req.data |
| 175 | captured_bodies.append(raw if raw is not None else b"") |
| 176 | return _mock_ok() |
| 177 | |
| 178 | with unittest.mock.patch("urllib.request.urlopen", side_effect=_capture): |
| 179 | _post_json("https://hub.test/api/v1/domains", payload, "tok") |
| 180 | |
| 181 | body = json.loads(captured_bodies[0].decode("utf-8")) |
| 182 | assert body["description"] == unicode_desc |
| 183 | |
| 184 | |
| 185 | def test_post_json_409_does_not_modify_state() -> None: |
| 186 | """Multiple 409 errors in a row do not corrupt any shared state.""" |
| 187 | payload = _PublishPayload( |
| 188 | author_slug="alice", |
| 189 | slug="conflict", |
| 190 | display_name="X", |
| 191 | description="Y", |
| 192 | capabilities=_Capabilities(), |
| 193 | viewer_type="v", |
| 194 | version="0.1.0", |
| 195 | ) |
| 196 | err = urllib.error.HTTPError( |
| 197 | url="https://hub.test/api/v1/domains", |
| 198 | code=409, |
| 199 | msg="Conflict", |
| 200 | hdrs=http.client.HTTPMessage(), |
| 201 | fp=io.BytesIO(b'{"error": "already_exists"}'), |
| 202 | ) |
| 203 | with unittest.mock.patch("urllib.request.urlopen", side_effect=err): |
| 204 | for _ in range(50): |
| 205 | with pytest.raises(urllib.error.HTTPError) as exc_info: |
| 206 | _post_json("https://hub.test/api/v1/domains", payload, "tok") |
| 207 | assert exc_info.value.code == 409 |
| 208 | |
| 209 | |
| 210 | # --------------------------------------------------------------------------- |
| 211 | # CLI stress tests |
| 212 | # --------------------------------------------------------------------------- |
| 213 | |
| 214 | |
| 215 | def test_cli_publish_large_description(repo: pathlib.Path) -> None: |
| 216 | """CLI accepts --description up to 4000 characters and transmits verbatim.""" |
| 217 | long_desc = "A" * 4000 |
| 218 | large_args = [ |
| 219 | "domains", "publish", |
| 220 | "--author", "alice", |
| 221 | "--slug", "largdesc", |
| 222 | "--name", "Large", |
| 223 | "--description", long_desc, |
| 224 | "--viewer-type", "genome", |
| 225 | "--capabilities", '{"merge_semantics": "three_way"}', |
| 226 | "--hub", "https://hub.test", |
| 227 | ] |
| 228 | captured: list[bytes] = [] |
| 229 | |
| 230 | def _capture(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock: |
| 231 | raw = req.data |
| 232 | captured.append(raw if raw is not None else b"") |
| 233 | return _mock_ok() |
| 234 | |
| 235 | with unittest.mock.patch("urllib.request.urlopen", side_effect=_capture): |
| 236 | result = runner.invoke(cli, large_args) |
| 237 | |
| 238 | assert result.exit_code == 0, result.output |
| 239 | body = json.loads(captured[0]) |
| 240 | assert body["description"] == long_desc |
| 241 | |
| 242 | |
| 243 | def test_cli_publish_slug_with_hyphens(repo: pathlib.Path) -> None: |
| 244 | """--slug with hyphens (e.g. 'spatial-3d') is transmitted as-is.""" |
| 245 | args = [ |
| 246 | "domains", "publish", |
| 247 | "--author", "alice", |
| 248 | "--slug", "spatial-3d", |
| 249 | "--name", "Spatial 3D", |
| 250 | "--description", "Version 3-D scenes", |
| 251 | "--viewer-type", "spatial", |
| 252 | "--capabilities", '{"merge_semantics": "three_way"}', |
| 253 | "--hub", "https://hub.test", |
| 254 | ] |
| 255 | captured: list[bytes] = [] |
| 256 | |
| 257 | def _capture(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock: |
| 258 | raw = req.data |
| 259 | captured.append(raw if raw is not None else b"") |
| 260 | return _mock_ok() |
| 261 | |
| 262 | with unittest.mock.patch("urllib.request.urlopen", side_effect=_capture): |
| 263 | result = runner.invoke(cli, args) |
| 264 | |
| 265 | assert result.exit_code == 0, result.output |
| 266 | body = json.loads(captured[0]) |
| 267 | assert body["slug"] == "spatial-3d" |
| 268 | |
| 269 | |
| 270 | def test_cli_publish_hub_url_propagated(repo: pathlib.Path) -> None: |
| 271 | """--hub URL override is used as the request endpoint.""" |
| 272 | custom_hub = "https://custom.musehub.example.com" |
| 273 | captured_urls: list[str] = [] |
| 274 | |
| 275 | def _capture(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock: |
| 276 | captured_urls.append(req.full_url) |
| 277 | return _mock_ok() |
| 278 | |
| 279 | with unittest.mock.patch("urllib.request.urlopen", side_effect=_capture): |
| 280 | result = runner.invoke(cli, _REQUIRED_ARGS[:-2] + ["--hub", custom_hub]) |
| 281 | |
| 282 | assert result.exit_code == 0, result.output |
| 283 | assert captured_urls[0].startswith(custom_hub) |
| 284 | |
| 285 | |
| 286 | def test_cli_publish_json_roundtrip(repo: pathlib.Path) -> None: |
| 287 | """--json output is valid JSON with all expected keys.""" |
| 288 | with unittest.mock.patch("urllib.request.urlopen", return_value=_mock_ok()): |
| 289 | result = runner.invoke(cli, _REQUIRED_ARGS + ["--json"]) |
| 290 | |
| 291 | assert result.exit_code == 0, result.output |
| 292 | data = json.loads(result.output.strip()) |
| 293 | assert "scoped_id" in data |
| 294 | assert "manifest_hash" in data |
| 295 | |
| 296 | |
| 297 | def test_cli_publish_version_semver(repo: pathlib.Path) -> None: |
| 298 | """--version is passed through without modification.""" |
| 299 | captured: list[bytes] = [] |
| 300 | |
| 301 | def _capture(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock: |
| 302 | raw = req.data |
| 303 | captured.append(raw if raw is not None else b"") |
| 304 | return _mock_ok() |
| 305 | |
| 306 | with unittest.mock.patch("urllib.request.urlopen", side_effect=_capture): |
| 307 | result = runner.invoke(cli, _REQUIRED_ARGS + ["--version", "2.14.0-beta.1"]) |
| 308 | |
| 309 | assert result.exit_code == 0, result.output |
| 310 | assert json.loads(captured[0])["version"] == "2.14.0-beta.1" |
| 311 | |
| 312 | |
| 313 | # --------------------------------------------------------------------------- |
| 314 | # Thread safety |
| 315 | # --------------------------------------------------------------------------- |
| 316 | |
| 317 | |
| 318 | def test_post_json_concurrent_thread_safety() -> None: |
| 319 | """10 concurrent threads invoking _post_json do not race on mock state.""" |
| 320 | # CliRunner is not thread-safe (StringIO), so we test the lower-level |
| 321 | # _post_json helper directly — this is what the CLI delegates to. |
| 322 | counter: list[int] = [0] |
| 323 | lock = threading.Lock() |
| 324 | |
| 325 | def _count_and_ok(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock: |
| 326 | with lock: |
| 327 | counter[0] += 1 |
| 328 | return _mock_ok() |
| 329 | |
| 330 | payload = _PublishPayload( |
| 331 | author_slug="alice", |
| 332 | slug="genomics", |
| 333 | display_name="Genomics", |
| 334 | description="Version DNA", |
| 335 | capabilities=_Capabilities(merge_semantics="three_way"), |
| 336 | viewer_type="genome", |
| 337 | version="0.1.0", |
| 338 | ) |
| 339 | |
| 340 | with unittest.mock.patch("urllib.request.urlopen", side_effect=_count_and_ok): |
| 341 | with concurrent.futures.ThreadPoolExecutor(max_workers=10) as pool: |
| 342 | futures = [ |
| 343 | pool.submit(_post_json, "https://hub.test/api/v1/domains", payload, "tok") |
| 344 | for _ in range(10) |
| 345 | ] |
| 346 | results = [f.result() for f in futures] |
| 347 | |
| 348 | assert len(results) == 10 |
| 349 | assert all(r["scoped_id"] == "@alice/genomics" for r in results) |
| 350 | assert counter[0] == 10 |
| 351 | |
| 352 | |
| 353 | # --------------------------------------------------------------------------- |
| 354 | # End-to-end integration |
| 355 | # --------------------------------------------------------------------------- |
| 356 | |
| 357 | |
| 358 | def test_e2e_publish_complete_payload_structure(repo: pathlib.Path) -> None: |
| 359 | """E2E: full publish sends author_slug, slug, display_name, description, capabilities, viewer_type, version.""" |
| 360 | captured: list[bytes] = [] |
| 361 | |
| 362 | def _capture(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock: |
| 363 | raw = req.data |
| 364 | captured.append(raw if raw is not None else b"") |
| 365 | return _mock_ok() |
| 366 | |
| 367 | with unittest.mock.patch("urllib.request.urlopen", side_effect=_capture): |
| 368 | result = runner.invoke(cli, _REQUIRED_ARGS + ["--version", "1.0.0"]) |
| 369 | |
| 370 | assert result.exit_code == 0, result.output |
| 371 | body = json.loads(captured[0]) |
| 372 | |
| 373 | # All required fields present |
| 374 | assert body["author_slug"] == "alice" |
| 375 | assert body["slug"] == "genomics" |
| 376 | assert body["display_name"] == "Genomics" |
| 377 | assert body["description"] == "Version DNA sequences" |
| 378 | assert body["viewer_type"] == "genome" |
| 379 | assert body["version"] == "1.0.0" |
| 380 | |
| 381 | # Capabilities structure |
| 382 | caps = body["capabilities"] |
| 383 | assert isinstance(caps, dict) |
| 384 | assert "dimensions" in caps |
| 385 | assert isinstance(caps["dimensions"], list) |
| 386 | |
| 387 | |
| 388 | def test_e2e_publish_capabilities_auto_from_midi_plugin(repo: pathlib.Path) -> None: |
| 389 | """E2E: capabilities auto-derived from midi plugin contain correct dimensions.""" |
| 390 | no_caps_args = [a for a in _REQUIRED_ARGS if a not in ("--capabilities",)] |
| 391 | # Remove the JSON value immediately after --capabilities |
| 392 | filtered: list[str] = [] |
| 393 | skip_next = False |
| 394 | for arg in _REQUIRED_ARGS: |
| 395 | if skip_next: |
| 396 | skip_next = False |
| 397 | continue |
| 398 | if arg == "--capabilities": |
| 399 | skip_next = True |
| 400 | continue |
| 401 | filtered.append(arg) |
| 402 | |
| 403 | captured: list[bytes] = [] |
| 404 | |
| 405 | def _capture(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock: |
| 406 | raw = req.data |
| 407 | captured.append(raw if raw is not None else b"") |
| 408 | return _mock_ok() |
| 409 | |
| 410 | with unittest.mock.patch("urllib.request.urlopen", side_effect=_capture): |
| 411 | result = runner.invoke(cli, filtered) |
| 412 | |
| 413 | assert result.exit_code == 0, result.output |
| 414 | body = json.loads(captured[0]) |
| 415 | dims = body["capabilities"]["dimensions"] |
| 416 | # MIDI plugin has 21 dimensions — at minimum should have "notes" |
| 417 | names = [d["name"] for d in dims] |
| 418 | assert "notes" in names |
| 419 | assert len(dims) >= 5 |
| 420 | |
| 421 | |
| 422 | def test_e2e_publish_400_sequential_calls_stable(repo: pathlib.Path) -> None: |
| 423 | """E2E stress: 400 sequential publish invocations all succeed. |
| 424 | |
| 425 | The wall-clock budget is intentionally generous (120s) to accommodate |
| 426 | GitHub Actions' shared runners, which can be 3-4× slower than a |
| 427 | developer laptop. The assertion guards against catastrophic regressions |
| 428 | (infinite loops, exponential backoff bugs) rather than raw throughput. |
| 429 | """ |
| 430 | with unittest.mock.patch("urllib.request.urlopen", return_value=_mock_ok()): |
| 431 | start = time.monotonic() |
| 432 | for i in range(400): |
| 433 | result = runner.invoke(cli, _REQUIRED_ARGS) |
| 434 | assert result.exit_code == 0, f"Run {i} failed: {result.output}" |
| 435 | elapsed = time.monotonic() - start |
| 436 | |
| 437 | assert elapsed < 120.0, f"400 CLI invocations took {elapsed:.1f}s" |