gabriel / muse public
test_cmd_maintenance.py python
349 lines 13.2 KB
Raw
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor ⚠ breaking 30 days ago
1 """Tests for ``muse maintenance`` — scheduled store maintenance orchestration.
2
3 Coverage tiers:
4 - Unit: run records timestamp; run --task gc; run --task verify-objects;
5 run --all; run --dry-run; run --json schema; status text + JSON;
6 schedule --period-hours; schedule --enable/--disable;
7 status reflects schedule config; no-config defaults
8 - Integration: verify-objects detects corrupt objects; gc cleans unreachable
9 blobs; json result keys correct; dry-run produces no mutations
10 - Security: no ANSI injection in task output
11 - Stress: verify-objects on 100 objects completes and counts correctly
12 """
13
14 from __future__ import annotations
15 from collections.abc import Mapping
16
17 import datetime
18 import json
19 import pathlib
20 import time
21 import unittest.mock
22
23 import pytest
24
25 from tests.cli_test_helper import CliRunner
26 from muse.core.object_store import object_path, write_object
27 from muse.core.paths import maintenance_json_path, muse_dir
28
29 from muse.core.store import SnapshotRecord, write_snapshot
30 from muse.core.types import Manifest, blob_id
31
32 runner = CliRunner()
33
34 _REPO_ID = "maintenance-test"
35
36
37 # ---------------------------------------------------------------------------
38 # Helpers
39 # ---------------------------------------------------------------------------
40
41
42
43
44 def _init_repo(path: pathlib.Path) -> pathlib.Path:
45 dot_muse = muse_dir(path)
46 for d in ("commits", "snapshots", "objects", "refs/heads", "code"):
47 (dot_muse / d).mkdir(parents=True, exist_ok=True)
48 (dot_muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
49 (dot_muse / "repo.json").write_text(
50 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
51 )
52 return path
53
54
55 def _env(repo: pathlib.Path) -> Mapping[str, str]:
56 return {"MUSE_REPO_ROOT": str(repo)}
57
58
59 def _invoke(args: list[str], repo: pathlib.Path) -> tuple[int, str, str]:
60 result = runner.invoke(None, args, env=_env(repo))
61 return result.exit_code, result.stdout, result.stderr
62
63
64 def _maint_config(repo: pathlib.Path) -> pathlib.Path:
65 return maintenance_json_path(repo)
66
67
68 def _write_object(repo: pathlib.Path, content: bytes) -> str:
69 obj_id = blob_id(content)
70 write_object(repo, obj_id, content)
71 return obj_id
72
73
74 # ---------------------------------------------------------------------------
75 # Unit — run (default)
76 # ---------------------------------------------------------------------------
77
78
79 class TestRun:
80 def test_run_exits_zero(self, tmp_path: pathlib.Path) -> None:
81 repo = _init_repo(tmp_path / "repo")
82 rc, out, err = _invoke(["maintenance", "run"], repo)
83 assert rc == 0
84
85 def test_run_records_timestamp(self, tmp_path: pathlib.Path) -> None:
86 repo = _init_repo(tmp_path / "repo")
87 _invoke(["maintenance", "run"], repo)
88 cfg = json.loads(_maint_config(repo).read_text())
89 assert "last_run" in cfg
90 assert "gc" in cfg["last_run"]
91
92 def test_run_creates_config_if_missing(self, tmp_path: pathlib.Path) -> None:
93 repo = _init_repo(tmp_path / "repo")
94 assert not _maint_config(repo).exists()
95 _invoke(["maintenance", "run"], repo)
96 assert _maint_config(repo).exists()
97
98 def test_run_task_gc(self, tmp_path: pathlib.Path) -> None:
99 repo = _init_repo(tmp_path / "repo")
100 rc, out, err = _invoke(["maintenance", "run", "--task", "gc"], repo)
101 assert rc == 0
102 cfg = json.loads(_maint_config(repo).read_text())
103 assert "gc" in cfg["last_run"]
104
105 def test_run_task_verify_objects(self, tmp_path: pathlib.Path) -> None:
106 repo = _init_repo(tmp_path / "repo")
107 _write_object(repo, b"hello")
108 rc, out, err = _invoke(
109 ["maintenance", "run", "--task", "verify-objects"], repo
110 )
111 assert rc == 0
112 cfg = json.loads(_maint_config(repo).read_text())
113 assert "verify-objects" in cfg["last_run"]
114
115 def test_run_all(self, tmp_path: pathlib.Path) -> None:
116 repo = _init_repo(tmp_path / "repo")
117 rc, out, err = _invoke(["maintenance", "run", "--all"], repo)
118 assert rc == 0
119 cfg = json.loads(_maint_config(repo).read_text())
120 assert "gc" in cfg["last_run"]
121 assert "verify-objects" in cfg["last_run"]
122
123 def test_run_dry_run_no_config_written(self, tmp_path: pathlib.Path) -> None:
124 repo = _init_repo(tmp_path / "repo")
125 _invoke(["maintenance", "run", "--dry-run"], repo)
126 # dry-run should NOT persist timestamps
127 if _maint_config(repo).exists():
128 cfg = json.loads(_maint_config(repo).read_text())
129 assert "last_run" not in cfg or not cfg.get("last_run")
130
131 def test_run_json_schema(self, tmp_path: pathlib.Path) -> None:
132 repo = _init_repo(tmp_path / "repo")
133 rc, out, err = _invoke(["maintenance", "run", "--json"], repo)
134 assert rc == 0
135 data = json.loads(out)
136 assert "tasks_run" in data
137 assert "results" in data
138 assert "dry_run" in data
139 assert "duration_ms" in data
140
141 def test_run_json_tasks_run_is_list(self, tmp_path: pathlib.Path) -> None:
142 repo = _init_repo(tmp_path / "repo")
143 rc, out, err = _invoke(
144 ["maintenance", "run", "--task", "gc", "--json"], repo
145 )
146 data = json.loads(out)
147 assert isinstance(data["tasks_run"], list)
148 assert "gc" in data["tasks_run"]
149
150 def test_run_unknown_task_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
151 repo = _init_repo(tmp_path / "repo")
152 rc, out, err = _invoke(
153 ["maintenance", "run", "--task", "bogus-task"], repo
154 )
155 assert rc != 0
156
157 def test_run_dry_run_flag_in_json(self, tmp_path: pathlib.Path) -> None:
158 repo = _init_repo(tmp_path / "repo")
159 rc, out, err = _invoke(
160 ["maintenance", "run", "--dry-run", "--json"], repo
161 )
162 data = json.loads(out)
163 assert data["dry_run"] is True
164
165
166 # ---------------------------------------------------------------------------
167 # Unit — status
168 # ---------------------------------------------------------------------------
169
170
171 class TestStatus:
172 def test_status_no_config(self, tmp_path: pathlib.Path) -> None:
173 repo = _init_repo(tmp_path / "repo")
174 rc, out, err = _invoke(["maintenance", "status"], repo)
175 assert rc == 0
176 assert "never" in out.lower() or "no" in out.lower() or "disabled" in out.lower()
177
178 def test_status_json_no_config(self, tmp_path: pathlib.Path) -> None:
179 repo = _init_repo(tmp_path / "repo")
180 rc, out, err = _invoke(["maintenance", "status", "--json"], repo)
181 assert rc == 0
182 data = json.loads(out)
183 assert "enabled" in data
184 assert "period_hours" in data
185 assert "last_run" in data
186
187 def test_status_shows_last_run_after_run(self, tmp_path: pathlib.Path) -> None:
188 repo = _init_repo(tmp_path / "repo")
189 _invoke(["maintenance", "run", "--task", "gc"], repo)
190 rc, out, err = _invoke(["maintenance", "status"], repo)
191 assert rc == 0
192 assert "gc" in out.lower()
193
194 def test_status_json_has_last_run_timestamp(self, tmp_path: pathlib.Path) -> None:
195 repo = _init_repo(tmp_path / "repo")
196 _invoke(["maintenance", "run", "--task", "gc"], repo)
197 rc, out, err = _invoke(["maintenance", "status", "--json"], repo)
198 data = json.loads(out)
199 assert "gc" in data["last_run"]
200 # last_run entries are now {timestamp, status, duration_ms} records
201 record = data["last_run"]["gc"]
202 assert isinstance(record, dict)
203 assert "T" in record["timestamp"]
204 assert record["status"] in ("ok", "error")
205
206
207 # ---------------------------------------------------------------------------
208 # Unit — schedule
209 # ---------------------------------------------------------------------------
210
211
212 class TestSchedule:
213 def test_schedule_period_hours(self, tmp_path: pathlib.Path) -> None:
214 repo = _init_repo(tmp_path / "repo")
215 rc, out, err = _invoke(
216 ["maintenance", "schedule", "--period-hours", "48"], repo
217 )
218 assert rc == 0
219 cfg = json.loads(_maint_config(repo).read_text())
220 assert cfg["period_hours"] == 48
221
222 def test_schedule_disable(self, tmp_path: pathlib.Path) -> None:
223 repo = _init_repo(tmp_path / "repo")
224 _invoke(["maintenance", "schedule", "--period-hours", "24"], repo)
225 rc, out, err = _invoke(["maintenance", "schedule", "--disable"], repo)
226 assert rc == 0
227 cfg = json.loads(_maint_config(repo).read_text())
228 assert cfg["enabled"] is False
229
230 def test_schedule_enable(self, tmp_path: pathlib.Path) -> None:
231 repo = _init_repo(tmp_path / "repo")
232 _invoke(["maintenance", "schedule", "--disable"], repo)
233 rc, out, err = _invoke(["maintenance", "schedule", "--enable"], repo)
234 assert rc == 0
235 cfg = json.loads(_maint_config(repo).read_text())
236 assert cfg["enabled"] is True
237
238 def test_schedule_status_reflects_period(self, tmp_path: pathlib.Path) -> None:
239 repo = _init_repo(tmp_path / "repo")
240 _invoke(["maintenance", "schedule", "--period-hours", "72"], repo)
241 rc, out, err = _invoke(["maintenance", "status", "--json"], repo)
242 data = json.loads(out)
243 assert data["period_hours"] == 72
244
245 def test_schedule_invalid_period_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
246 repo = _init_repo(tmp_path / "repo")
247 rc, out, err = _invoke(
248 ["maintenance", "schedule", "--period-hours", "-1"], repo
249 )
250 assert rc != 0
251
252 def test_schedule_default_period_is_24(self, tmp_path: pathlib.Path) -> None:
253 repo = _init_repo(tmp_path / "repo")
254 _invoke(["maintenance", "schedule"], repo)
255 cfg = json.loads(_maint_config(repo).read_text())
256 assert cfg.get("period_hours", 24) == 24
257
258
259 # ---------------------------------------------------------------------------
260 # Integration — verify-objects detects corruption
261 # ---------------------------------------------------------------------------
262
263
264 class TestVerifyObjectsIntegration:
265 def test_verify_objects_passes_on_clean_store(self, tmp_path: pathlib.Path) -> None:
266 repo = _init_repo(tmp_path / "repo")
267 for i in range(5):
268 _write_object(repo, f"content-{i}".encode())
269 rc, out, err = _invoke(
270 ["maintenance", "run", "--task", "verify-objects", "--json"], repo
271 )
272 assert rc == 0
273 data = json.loads(out)
274 assert data["results"]["verify-objects"]["failed"] == 0
275
276 def test_verify_objects_detects_corrupt_object(self, tmp_path: pathlib.Path) -> None:
277 import os
278 repo = _init_repo(tmp_path / "repo")
279 obj_id = _write_object(repo, b"good content")
280 # corrupt the file (objects are stored read-only, chmod first)
281 obj_path = object_path(repo, obj_id)
282 obj_path.chmod(0o644)
283 obj_path.write_bytes(b"corrupted data")
284
285 rc, out, err = _invoke(
286 ["maintenance", "run", "--task", "verify-objects", "--json"], repo
287 )
288 # Should still exit 0 but report failures
289 data = json.loads(out)
290 assert data["results"]["verify-objects"]["failed"] >= 1
291
292 def test_verify_objects_json_has_checked_count(self, tmp_path: pathlib.Path) -> None:
293 repo = _init_repo(tmp_path / "repo")
294 for i in range(3):
295 _write_object(repo, f"item-{i}".encode())
296 rc, out, err = _invoke(
297 ["maintenance", "run", "--task", "verify-objects", "--json"], repo
298 )
299 data = json.loads(out)
300 assert data["results"]["verify-objects"]["checked"] == 3
301
302
303 # ---------------------------------------------------------------------------
304 # Stress — verify-objects on 100 objects
305 # ---------------------------------------------------------------------------
306
307
308 class TestStress:
309 def test_100_objects_verify_all_pass(self, tmp_path: pathlib.Path) -> None:
310 repo = _init_repo(tmp_path / "repo")
311 for i in range(100):
312 _write_object(repo, f"stress-object-{i:03d}".encode())
313 rc, out, err = _invoke(
314 ["maintenance", "run", "--task", "verify-objects", "--json"], repo
315 )
316 assert rc == 0
317 data = json.loads(out)
318 v = data["results"]["verify-objects"]
319 assert v["checked"] == 100
320 assert v["failed"] == 0
321
322
323 class TestRegisterFlags:
324 def test_default_json_out_is_false(self) -> None:
325 import argparse
326 from muse.cli.commands.maintenance import register
327 p = argparse.ArgumentParser()
328 subs = p.add_subparsers()
329 register(subs)
330 args = p.parse_args(["maintenance", "run"])
331 assert args.json_out is False
332
333 def test_json_flag_sets_json_out(self) -> None:
334 import argparse
335 from muse.cli.commands.maintenance import register
336 p = argparse.ArgumentParser()
337 subs = p.add_subparsers()
338 register(subs)
339 args = p.parse_args(["maintenance", "run", "--json"])
340 assert args.json_out is True
341
342 def test_j_shorthand_sets_json_out(self) -> None:
343 import argparse
344 from muse.cli.commands.maintenance import register
345 p = argparse.ArgumentParser()
346 subs = p.add_subparsers()
347 register(subs)
348 args = p.parse_args(["maintenance", "run", "-j"])
349 assert args.json_out is True
File History 1 commit
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 30 days ago