gabriel / muse public
test_stress_domains_publish.py python
437 lines 15.8 KB
19bb6d76 fix(ci): raise 400-call stress test deadline 30s → 120s for CI runners Gabriel Cardona <gabriel@tellurstori.com> 2d ago
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"