gabriel / muse public
test_coord_pull_null_records.py python
650 lines 27.7 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """
2 Tests for the bug: hub response with null/non-list records or null/non-integer
3 cursor causes unhandled TypeError that escapes run_pull as a raw traceback.
4
5 Root cause (coord_bus.py::pull_from_hub):
6
7 return _post_json(url, body, token)
8
9 The raw hub response is returned without validation. run_pull then does:
10
11 pulled_records: list[dict] = result.get("records", [])
12 cursor: int = result.get("cursor", 0)
13
14 When hub returns {"records": null}:
15 - result.get("records", []) → None (key EXISTS, default [] not used)
16 - if pulled_records: → False (None is falsy, so _write_remote_records skipped)
17 - len(pulled_records) → TypeError: object of type 'NoneType' has no len()
18
19 When hub returns {"records": ["a", "b"]} (list of strings not dicts):
20 - _write_remote_records iterates; rec.get("kind") → AttributeError on str
21
22 When hub returns {"cursor": null}:
23 - cursor = None; json.dumps({"cursor": None}) → "cursor": null in output
24 - text mode: f"cursor: {cursor}" → "cursor: None" — contract violated
25
26 When hub returns {"cursor": "evil"}:
27 - cursor = "evil"; propagated verbatim to JSON output
28
29 None of these are CoordBusError. run_pull only catches CoordBusError.
30 TypeError/AttributeError escape as raw tracebacks.
31
32 Fix location: pull_from_hub in coord_bus.py — validate response before returning.
33
34 Coverage:
35 Unit — pull_from_hub directly, all bad-value variants
36 Integration — run_pull with bad hub response, two layers deep
37 End-to-end — CLI output is valid JSON with no tracebacks
38 Stress — 50 consecutive pulls mixing good and bad responses
39 Performance — bad response path not slower than good path
40 Security — hub cannot inject arbitrary values into cursor output
41 Data integrity — cursor and count in output reflect reality
42 """
43 from __future__ import annotations
44
45 import argparse
46 import io
47 import json
48 import pathlib
49 import sys
50 import time
51 from unittest.mock import patch
52
53 import pytest
54
55 from muse.core._types import MsgpackDict, MsgpackValue
56
57 # ---------------------------------------------------------------------------
58 # Helpers
59 # ---------------------------------------------------------------------------
60
61 _FUTURE_TS = "2099-12-31T23:59:59+00:00"
62
63
64 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
65 (tmp_path / ".muse").mkdir(parents=True, exist_ok=True)
66 return tmp_path
67
68
69 def _good_record(i: int = 0) -> MsgpackDict:
70 return {
71 "kind": "reservation",
72 "record_uuid": f"res-{i:06d}",
73 "run_id": f"run-{i}",
74 "payload": {"reservation_id": f"res-{i:06d}", "expires_at": _FUTURE_TS},
75 "expires_at": _FUTURE_TS,
76 }
77
78
79 def _run_pull_with_hub_response(
80 tmp_path: pathlib.Path,
81 hub_response: MsgpackDict,
82 ) -> tuple[int | str | None, str]:
83 """
84 Run run_pull with pull_from_hub mocked to return hub_response.
85 Returns (exit_code, stdout).
86 exit_code is None for clean success, int for SystemExit, "CRASH" for unhandled exception.
87 """
88 root = _make_repo(tmp_path)
89 captured = io.StringIO()
90
91 # Mock _post_json (not pull_from_hub) so the validation in pull_from_hub runs.
92 with patch("muse.core.coord_bus._post_json", return_value=hub_response), \
93 patch("muse.cli.commands.coord_sync.require_repo", return_value=root), \
94 patch("muse.cli.commands.coord_sync._resolve_hub_and_signing",
95 return_value=("http://localhost:10003", "tok")), \
96 patch("sys.stdout", captured):
97 args = argparse.Namespace(
98 owner="torvalds", slug="linux",
99 fmt="json", hub_url=None,
100 since_id=0, limit=1000, kinds=[],
101 )
102 try:
103 from muse.cli.commands.coord_sync import run_pull
104 run_pull(args)
105 except SystemExit as exc:
106 return (exc.code, captured.getvalue())
107 except Exception as exc:
108 return ("CRASH", f"{type(exc).__name__}: {exc}")
109
110 return (None, captured.getvalue())
111
112
113 # =============================================================================
114 # 1. UNIT — pull_from_hub directly
115 # =============================================================================
116
117 class TestPullFromHubNullRecordsUnit:
118 """
119 Unit tests on coord_bus.pull_from_hub.
120 _post_json mocked to return bad responses.
121 Assert CoordBusError is raised, not TypeError/AttributeError.
122 """
123
124 # --- records field ---
125
126 def test_null_records_raises_coord_bus_error(self) -> None:
127 from muse.core.coord_bus import pull_from_hub, CoordBusError
128 with patch("muse.core.coord_bus._post_json",
129 return_value={"records": None, "cursor": 0}):
130 with pytest.raises(CoordBusError):
131 pull_from_hub("http://localhost:10003", "torvalds", "linux")
132
133 def test_null_records_never_raises_raw_typeerror(self) -> None:
134 """The confirmed crash: len(None) must not escape as TypeError."""
135 from muse.core.coord_bus import pull_from_hub, CoordBusError
136 with patch("muse.core.coord_bus._post_json",
137 return_value={"records": None, "cursor": 0}):
138 try:
139 pull_from_hub("http://localhost:10003", "torvalds", "linux")
140 except CoordBusError:
141 pass # correct
142 except TypeError as exc:
143 pytest.fail(f"Raw TypeError escaped pull_from_hub: {exc}")
144
145 @pytest.mark.parametrize("bad_records", [
146 "evil string",
147 42,
148 3.14,
149 True,
150 {"key": "val"},
151 ])
152 def test_non_list_records_raises_coord_bus_error(self, bad_records: MsgpackValue) -> None:
153 from muse.core.coord_bus import pull_from_hub, CoordBusError
154 with patch("muse.core.coord_bus._post_json",
155 return_value={"records": bad_records, "cursor": 0}):
156 with pytest.raises(CoordBusError):
157 pull_from_hub("http://localhost:10003", "torvalds", "linux")
158
159 def test_list_of_strings_raises_coord_bus_error(self) -> None:
160 """List of strings would cause AttributeError on .get() — must be CoordBusError."""
161 from muse.core.coord_bus import pull_from_hub, CoordBusError
162 with patch("muse.core.coord_bus._post_json",
163 return_value={"records": ["a", "b", "c"], "cursor": 3}):
164 with pytest.raises(CoordBusError):
165 pull_from_hub("http://localhost:10003", "torvalds", "linux")
166
167 def test_list_of_strings_never_raises_raw_attributeerror(self) -> None:
168 from muse.core.coord_bus import pull_from_hub, CoordBusError
169 with patch("muse.core.coord_bus._post_json",
170 return_value={"records": ["a", "b"], "cursor": 2}):
171 try:
172 pull_from_hub("http://localhost:10003", "torvalds", "linux")
173 except CoordBusError:
174 pass
175 except AttributeError as exc:
176 pytest.fail(f"Raw AttributeError escaped pull_from_hub: {exc}")
177
178 def test_missing_records_key_defaults_to_empty_list(self) -> None:
179 """Hub omits records entirely — must default to [] not crash."""
180 from muse.core.coord_bus import pull_from_hub
181 with patch("muse.core.coord_bus._post_json",
182 return_value={"cursor": 0}):
183 result = pull_from_hub("http://localhost:10003", "torvalds", "linux")
184 assert result["records"] == []
185
186 def test_empty_records_list_is_valid(self) -> None:
187 from muse.core.coord_bus import pull_from_hub
188 with patch("muse.core.coord_bus._post_json",
189 return_value={"records": [], "cursor": 0}):
190 result = pull_from_hub("http://localhost:10003", "torvalds", "linux")
191 assert result["records"] == []
192 assert result["cursor"] == 0
193
194 def test_valid_records_list_passes_through(self) -> None:
195 from muse.core.coord_bus import pull_from_hub
196 records = [_good_record(i) for i in range(5)]
197 with patch("muse.core.coord_bus._post_json",
198 return_value={"records": records, "cursor": 5}):
199 result = pull_from_hub("http://localhost:10003", "torvalds", "linux")
200 assert len(result["records"]) == 5
201 assert result["cursor"] == 5
202
203 # --- cursor field ---
204
205 def test_null_cursor_raises_coord_bus_error(self) -> None:
206 from muse.core.coord_bus import pull_from_hub, CoordBusError
207 with patch("muse.core.coord_bus._post_json",
208 return_value={"records": [], "cursor": None}):
209 with pytest.raises(CoordBusError):
210 pull_from_hub("http://localhost:10003", "torvalds", "linux")
211
212 @pytest.mark.parametrize("bad_cursor", [
213 "evil",
214 [],
215 {},
216 "123abc",
217 ])
218 def test_non_integer_cursor_raises_coord_bus_error(self, bad_cursor: MsgpackValue) -> None:
219 from muse.core.coord_bus import pull_from_hub, CoordBusError
220 with patch("muse.core.coord_bus._post_json",
221 return_value={"records": [], "cursor": bad_cursor}):
222 with pytest.raises(CoordBusError):
223 pull_from_hub("http://localhost:10003", "torvalds", "linux")
224
225 def test_negative_cursor_raises_coord_bus_error(self) -> None:
226 from muse.core.coord_bus import pull_from_hub, CoordBusError
227 with patch("muse.core.coord_bus._post_json",
228 return_value={"records": [], "cursor": -1}):
229 with pytest.raises(CoordBusError):
230 pull_from_hub("http://localhost:10003", "torvalds", "linux")
231
232 def test_missing_cursor_key_defaults_to_zero(self) -> None:
233 from muse.core.coord_bus import pull_from_hub
234 with patch("muse.core.coord_bus._post_json",
235 return_value={"records": []}):
236 result = pull_from_hub("http://localhost:10003", "torvalds", "linux")
237 assert result["cursor"] == 0
238
239 def test_float_cursor_truncated(self) -> None:
240 """Float cursor from hub is truncated to int — no crash."""
241 from muse.core.coord_bus import pull_from_hub
242 with patch("muse.core.coord_bus._post_json",
243 return_value={"records": [], "cursor": 7.9}):
244 result = pull_from_hub("http://localhost:10003", "torvalds", "linux")
245 assert result["cursor"] == 7
246
247 def test_zero_cursor_valid(self) -> None:
248 from muse.core.coord_bus import pull_from_hub
249 with patch("muse.core.coord_bus._post_json",
250 return_value={"records": [], "cursor": 0}):
251 result = pull_from_hub("http://localhost:10003", "torvalds", "linux")
252 assert result["cursor"] == 0
253
254 # --- both null ---
255
256 def test_both_null_raises_coord_bus_error(self) -> None:
257 from muse.core.coord_bus import pull_from_hub, CoordBusError
258 with patch("muse.core.coord_bus._post_json",
259 return_value={"records": None, "cursor": None}):
260 with pytest.raises(CoordBusError):
261 pull_from_hub("http://localhost:10003", "torvalds", "linux")
262
263
264 # =============================================================================
265 # 2. INTEGRATION — run_pull with bad hub response
266 # =============================================================================
267
268 class TestRunPullNullRecordsIntegration:
269 """
270 Integration: run_pull with pull_from_hub mocked at the boundary.
271 Asserts clean exit, no unhandled exceptions, correct JSON structure.
272 """
273
274 def test_null_records_exits_cleanly_not_crash(self, tmp_path: pathlib.Path) -> None:
275 code, output = _run_pull_with_hub_response(
276 tmp_path, {"records": None, "cursor": 0}
277 )
278 assert code != "CRASH", f"run_pull crashed: {output}"
279
280 def test_null_cursor_exits_cleanly_not_crash(self, tmp_path: pathlib.Path) -> None:
281 code, output = _run_pull_with_hub_response(
282 tmp_path, {"records": [], "cursor": None}
283 )
284 assert code != "CRASH", f"run_pull crashed: {output}"
285
286 def test_both_null_exits_cleanly_not_crash(self, tmp_path: pathlib.Path) -> None:
287 code, output = _run_pull_with_hub_response(
288 tmp_path, {"records": None, "cursor": None}
289 )
290 assert code != "CRASH", f"run_pull crashed: {output}"
291
292 def test_string_records_exits_cleanly_not_crash(self, tmp_path: pathlib.Path) -> None:
293 code, output = _run_pull_with_hub_response(
294 tmp_path, {"records": "evil", "cursor": 0}
295 )
296 assert code != "CRASH", f"run_pull crashed: {output}"
297
298 def test_list_of_strings_exits_cleanly_not_crash(self, tmp_path: pathlib.Path) -> None:
299 code, output = _run_pull_with_hub_response(
300 tmp_path, {"records": ["a", "b", "c"], "cursor": 3}
301 )
302 assert code != "CRASH", f"run_pull crashed: {output}"
303
304 def test_null_records_exits_with_code_1(self, tmp_path: pathlib.Path) -> None:
305 code, output = _run_pull_with_hub_response(
306 tmp_path, {"records": None, "cursor": 0}
307 )
308 assert code == 1, f"expected exit 1 for null records, got {code!r}"
309
310 def test_bad_response_json_output_has_no_traceback(self, tmp_path: pathlib.Path) -> None:
311 _, output = _run_pull_with_hub_response(
312 tmp_path, {"records": None, "cursor": None}
313 )
314 assert "Traceback" not in output
315 assert "TypeError" not in output
316 assert "AttributeError" not in output
317
318 def test_good_response_exits_cleanly(self, tmp_path: pathlib.Path) -> None:
319 records = [_good_record(i) for i in range(3)]
320 code, output = _run_pull_with_hub_response(
321 tmp_path, {"records": records, "cursor": 3}
322 )
323 assert code in (0, None), f"expected clean exit, got {code!r}"
324
325 def test_good_response_output_has_correct_count(self, tmp_path: pathlib.Path) -> None:
326 records = [_good_record(i) for i in range(3)]
327 code, output = _run_pull_with_hub_response(
328 tmp_path, {"records": records, "cursor": 3}
329 )
330 lines = [l for l in output.strip().splitlines() if l.strip()]
331 summary = json.loads(lines[-1])
332 assert summary["count"] == 3
333 assert summary["cursor"] == 3
334
335 def test_empty_records_exits_cleanly(self, tmp_path: pathlib.Path) -> None:
336 code, output = _run_pull_with_hub_response(
337 tmp_path, {"records": [], "cursor": 0}
338 )
339 assert code in (0, None)
340
341 def test_text_mode_null_records_does_not_crash(self, tmp_path: pathlib.Path) -> None:
342 root = _make_repo(tmp_path)
343 with patch("muse.core.coord_bus._post_json",
344 return_value={"records": None, "cursor": 0}), \
345 patch("muse.cli.commands.coord_sync.require_repo", return_value=root), \
346 patch("muse.cli.commands.coord_sync._resolve_hub_and_signing",
347 return_value=("http://localhost:10003", "tok")), \
348 patch("sys.stdout", io.StringIO()):
349 args = argparse.Namespace(
350 owner="torvalds", slug="linux",
351 fmt="text", hub_url=None,
352 since_id=0, limit=1000, kinds=[],
353 )
354 try:
355 from muse.cli.commands.coord_sync import run_pull
356 run_pull(args)
357 except SystemExit:
358 pass
359 except Exception as exc:
360 pytest.fail(f"text mode crashed: {type(exc).__name__}: {exc}")
361
362
363 # =============================================================================
364 # 3. END-TO-END — CLI output is always valid JSON with no exception text
365 # =============================================================================
366
367 class TestRunPullNullRecordsEndToEnd:
368
369 @pytest.mark.parametrize("bad_response", [
370 {"records": None, "cursor": 0},
371 {"records": None, "cursor": None},
372 {"records": [], "cursor": None},
373 {"records": "evil", "cursor": 0},
374 {"records": 42, "cursor": 0},
375 {"records": ["a", "b"], "cursor": 2},
376 {"records": None},
377 {"cursor": 0},
378 {},
379 ])
380 def test_every_bad_response_produces_valid_json_output(self, tmp_path: pathlib.Path, bad_response: MsgpackDict) -> None:
381 code, output = _run_pull_with_hub_response(tmp_path, bad_response)
382 assert code != "CRASH", f"crashed on {bad_response}: {output}"
383 lines = [l for l in output.strip().splitlines() if l.strip()]
384 assert lines, f"no output for {bad_response}"
385 for line in lines:
386 try:
387 json.loads(line)
388 except json.JSONDecodeError:
389 pytest.fail(f"non-JSON output for {bad_response!r}: {line!r}")
390
391 def test_output_never_contains_exception_class_names(self, tmp_path: pathlib.Path) -> None:
392 for bad in [None, "evil", [], 42]:
393 _, output = _run_pull_with_hub_response(
394 tmp_path, {"records": bad, "cursor": 0}
395 )
396 for forbidden in ("TypeError", "AttributeError", "Traceback",
397 "most recent call", "ValueError"):
398 assert forbidden not in output, (
399 f"{forbidden!r} leaked for records={bad!r}:\n{output}"
400 )
401
402 def test_output_json_schema_complete_on_bad_response(self, tmp_path: pathlib.Path) -> None:
403 """All required keys present in output even on error."""
404 _, output = _run_pull_with_hub_response(
405 tmp_path, {"records": None, "cursor": 0}
406 )
407 lines = [l for l in output.strip().splitlines() if l.strip()]
408 # At minimum there should be an error line
409 assert lines, "no output at all"
410 # The last line must be parseable JSON
411 summary = json.loads(lines[-1])
412 assert isinstance(summary, dict)
413
414
415 # =============================================================================
416 # 4. STRESS — many pulls mixing good and bad responses
417 # =============================================================================
418
419 class TestRunPullNullRecordsStress:
420
421 def _pull(self, tmp_path_subdir: pathlib.Path, hub_response: MsgpackDict) -> tuple[int | str | None, str]:
422 return _run_pull_with_hub_response(tmp_path_subdir, hub_response)
423
424 def test_50_consecutive_null_records_no_crash(self, tmp_path: pathlib.Path) -> None:
425 for i in range(50):
426 code, output = self._pull(
427 tmp_path / str(i),
428 {"records": None, "cursor": i}
429 )
430 assert code != "CRASH", f"crashed on iteration {i}: {output}"
431
432 def test_alternating_good_and_null_no_crash(self, tmp_path: pathlib.Path) -> None:
433 for i in range(20):
434 if i % 2 == 0:
435 response = {"records": [_good_record(i)], "cursor": i + 1}
436 else:
437 response = {"records": None, "cursor": None}
438 code, output = self._pull(tmp_path / str(i), response)
439 assert code != "CRASH", f"crashed on iteration {i}: {output}"
440
441 def test_all_bad_response_types_in_sequence_no_crash(self, tmp_path: pathlib.Path) -> None:
442 bad_responses = [
443 {"records": None, "cursor": 0},
444 {"records": None, "cursor": None},
445 {"records": "string", "cursor": 0},
446 {"records": 42, "cursor": 0},
447 {"records": True, "cursor": 0},
448 {"records": ["str1", "str2"], "cursor": 2},
449 {"records": {}, "cursor": 0},
450 {"records": [], "cursor": None},
451 {"records": [], "cursor": "bad"},
452 {"records": [], "cursor": -1},
453 ]
454 for i, response in enumerate(bad_responses):
455 code, output = self._pull(tmp_path / str(i), response)
456 assert code != "CRASH", f"crashed on response {response}: {output}"
457 assert code == 1, f"expected exit 1 for {response}, got {code!r}"
458
459
460 # =============================================================================
461 # 5. PERFORMANCE — bad response path overhead is negligible
462 # =============================================================================
463
464 class TestRunPullNullRecordsPerformance:
465
466 def _time_pull(self, tmp_path: pathlib.Path, response: MsgpackDict) -> float:
467 t0 = time.monotonic()
468 _run_pull_with_hub_response(tmp_path, response)
469 return time.monotonic() - t0
470
471 def test_null_response_not_slower_than_good_response(self, tmp_path: pathlib.Path) -> None:
472 # warm up
473 self._time_pull(tmp_path / "w1", {"records": [], "cursor": 0})
474 self._time_pull(tmp_path / "w2", {"records": None, "cursor": 0})
475
476 good = self._time_pull(tmp_path / "g", {"records": [], "cursor": 0})
477 bad = self._time_pull(tmp_path / "b", {"records": None, "cursor": 0})
478
479 assert bad < max(good * 10, 0.100), (
480 f"null response ({bad:.4f}s) unexpectedly slower than good ({good:.4f}s)"
481 )
482
483 def test_50_null_pulls_under_1s(self, tmp_path: pathlib.Path) -> None:
484 t0 = time.monotonic()
485 for i in range(50):
486 _run_pull_with_hub_response(
487 tmp_path / str(i), {"records": None, "cursor": 0}
488 )
489 elapsed = time.monotonic() - t0
490 assert elapsed < 1.0, f"50 null-response pulls took {elapsed:.3f}s (> 1s)"
491
492
493 # =============================================================================
494 # 6. SECURITY — hub cannot inject arbitrary cursor values into output
495 # =============================================================================
496
497 class TestRunPullNullRecordsSecurity:
498
499 @pytest.mark.parametrize("attack_cursor", [
500 "__import__('os').system('id')",
501 "${7*7}",
502 "{{7*7}}",
503 "' OR 1=1 --",
504 "\x00\x01\x02",
505 "9" * 10000,
506 "1e308",
507 "inf",
508 -9999999999,
509 ])
510 def test_attack_cursor_raises_coord_bus_error_not_exec(self, attack_cursor: str | int) -> None:
511 from muse.core.coord_bus import pull_from_hub, CoordBusError
512 with patch("muse.core.coord_bus._post_json",
513 return_value={"records": [], "cursor": attack_cursor}):
514 try:
515 pull_from_hub("http://localhost:10003", "torvalds", "linux")
516 except CoordBusError:
517 pass
518 except Exception as exc:
519 pytest.fail(
520 f"Attack cursor {attack_cursor!r} escaped as "
521 f"{type(exc).__name__}: {exc}"
522 )
523
524 def test_null_cursor_never_appears_in_output_as_none_string(self, tmp_path: pathlib.Path) -> None:
525 """'None' must not appear in CLI output — it means Python None leaked."""
526 _, output = _run_pull_with_hub_response(
527 tmp_path, {"records": [], "cursor": None}
528 )
529 assert '"cursor": null' not in output or True # null is ok in error JSON
530 # What must NOT happen: the Python repr "None" appearing as a string value
531 for line in output.strip().splitlines():
532 if not line.strip():
533 continue
534 parsed = json.loads(line)
535 # If cursor appears in output it must be an int or absent
536 if "cursor" in parsed:
537 assert isinstance(parsed["cursor"], int) or parsed.get("failed"), (
538 f"cursor in output is not an int: {parsed['cursor']!r}"
539 )
540
541 def test_extremely_large_cursor_rejected(self, tmp_path: pathlib.Path) -> None:
542 """Hub claiming cursor=2^63 must not be accepted verbatim."""
543 huge = 2**63
544 code, output = _run_pull_with_hub_response(
545 tmp_path, {"records": [], "cursor": huge}
546 )
547 assert code != "CRASH"
548 lines = [l for l in output.strip().splitlines() if l.strip()]
549 summary = json.loads(lines[-1])
550 if not summary.get("failed"):
551 # If it succeeded, cursor must be sane
552 assert summary.get("cursor", 0) <= 10**15, (
553 f"2^63 cursor accepted verbatim: {summary}"
554 )
555
556
557 # =============================================================================
558 # 7. DATA INTEGRITY — count and cursor in output reflect reality
559 # =============================================================================
560
561 class TestRunPullNullRecordsDataIntegrity:
562
563 def test_count_is_zero_when_records_null(self, tmp_path: pathlib.Path) -> None:
564 """count in output must be 0 (not a crash) when hub returns null records."""
565 code, output = _run_pull_with_hub_response(
566 tmp_path, {"records": None, "cursor": 0}
567 )
568 assert code != "CRASH"
569 # Output must contain some line indicating failure
570 lines = [l for l in output.strip().splitlines() if l.strip()]
571 assert lines
572
573 def test_count_equals_len_of_returned_records(self, tmp_path: pathlib.Path) -> None:
574 records = [_good_record(i) for i in range(7)]
575 _, output = _run_pull_with_hub_response(
576 tmp_path, {"records": records, "cursor": 7}
577 )
578 lines = [l for l in output.strip().splitlines() if l.strip()]
579 summary = json.loads(lines[-1])
580 assert summary["count"] == 7
581
582 def test_cursor_in_output_matches_hub_cursor(self, tmp_path: pathlib.Path) -> None:
583 records = [_good_record(i) for i in range(3)]
584 _, output = _run_pull_with_hub_response(
585 tmp_path, {"records": records, "cursor": 42}
586 )
587 lines = [l for l in output.strip().splitlines() if l.strip()]
588 summary = json.loads(lines[-1])
589 assert summary["cursor"] == 42
590
591 def test_cursor_zero_when_no_records(self, tmp_path: pathlib.Path) -> None:
592 _, output = _run_pull_with_hub_response(
593 tmp_path, {"records": [], "cursor": 0}
594 )
595 lines = [l for l in output.strip().splitlines() if l.strip()]
596 summary = json.loads(lines[-1])
597 assert summary["cursor"] == 0
598
599 def test_records_written_to_disk_on_good_response(self, tmp_path: pathlib.Path) -> None:
600 """Pulled records must actually be written to the remote/ directory."""
601 root = _make_repo(tmp_path)
602 records = [_good_record(i) for i in range(3)]
603
604 with patch("muse.core.coord_bus._post_json",
605 return_value={"records": records, "cursor": 3}), \
606 patch("muse.cli.commands.coord_sync.require_repo", return_value=root), \
607 patch("muse.cli.commands.coord_sync._resolve_hub_and_signing",
608 return_value=("http://localhost:10003", "tok")), \
609 patch("sys.stdout", io.StringIO()):
610 args = argparse.Namespace(
611 owner="torvalds", slug="linux",
612 fmt="json", hub_url=None,
613 since_id=0, limit=1000, kinds=[],
614 )
615 try:
616 from muse.cli.commands.coord_sync import run_pull
617 run_pull(args)
618 except SystemExit:
619 pass
620
621 remote_dir = root / ".muse" / "coordination" / "remote"
622 written = list(remote_dir.rglob("*.json"))
623 assert len(written) == 3, f"expected 3 files written, got {len(written)}"
624
625 def test_null_records_writes_nothing_to_disk(self, tmp_path: pathlib.Path) -> None:
626 """When hub returns null records, nothing must be written to remote/."""
627 root = _make_repo(tmp_path)
628
629 with patch("muse.core.coord_bus._post_json",
630 return_value={"records": None, "cursor": 0}), \
631 patch("muse.cli.commands.coord_sync.require_repo", return_value=root), \
632 patch("muse.cli.commands.coord_sync._resolve_hub_and_signing",
633 return_value=("http://localhost:10003", "tok")), \
634 patch("sys.stdout", io.StringIO()):
635 args = argparse.Namespace(
636 owner="torvalds", slug="linux",
637 fmt="json", hub_url=None,
638 since_id=0, limit=1000, kinds=[],
639 )
640 try:
641 from muse.cli.commands.coord_sync import run_pull
642 run_pull(args)
643 except SystemExit:
644 pass
645 except Exception:
646 pass
647
648 remote_dir = root / ".muse" / "coordination" / "remote"
649 written = list(remote_dir.rglob("*.json")) if remote_dir.exists() else []
650 assert written == [], f"null records caused {len(written)} files to be written"
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 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 100 days ago