gabriel / muse public
test_social_cli.py python
524 lines 20.8 KB
Raw
sha256:ff478cfdcdd4b7fd6de89cb68896601a981f945634463275ec333bd20ca36402 Merge branch 'dev' into main Human 20 days ago
1 """Phase 01 TDD — ``muse social`` CLI surface.
2
3 RED → GREEN cycle. Run before implementation to confirm failures, then
4 implement until all pass.
5
6 Test tiers
7 ----------
8 TestSocialCliShape — command registered, all subcommands in --help,
9 all run_* importable, module docstring present.
10 TestSocialStateHelpers — pure state-layer logic: write_post, write_follow,
11 write_reaction, read_profile — no repo, no network.
12 TestSocialCliPost — muse social post: JSON output, file written,
13 body stored, reply_to wired, dedup by content.
14 TestSocialCliFollow — follow/unfollow: file created/deleted, idempotent.
15 TestSocialCliTimeline — timeline: sorted by created_at desc, --limit,
16 reply_to threading, empty feed.
17 TestSocialCliReact — react: file written, emoji stored.
18 TestSocialCliProfile — profile show (no args) and profile set.
19 TestSocialCliDocstrings — all public run_* and helpers carry docstrings.
20 """
21 from __future__ import annotations
22
23 import hashlib
24 import json
25 import pathlib
26 import sys
27 import time
28 import types
29
30 import pytest
31
32
33 # ---------------------------------------------------------------------------
34 # Invoke helper — same pattern as test_mist_cli.py
35 # ---------------------------------------------------------------------------
36
37 def invoke_social(args: list[str]) -> tuple[int, str, str]:
38 """Run ``muse social <args>`` in-process and capture stdout/stderr."""
39 from io import StringIO
40
41 old_stdout, old_stderr = sys.stdout, sys.stderr
42 sys.stdout = out = StringIO()
43 sys.stderr = err = StringIO()
44 exit_code = 0
45 try:
46 from muse.cli.app import main
47 main(["social"] + args)
48 except SystemExit as exc:
49 exit_code = int(exc.code) if exc.code is not None else 0
50 finally:
51 sys.stdout = old_stdout
52 sys.stderr = old_stderr
53 return exit_code, out.getvalue(), err.getvalue()
54
55
56 # ---------------------------------------------------------------------------
57 # Fixtures
58 # ---------------------------------------------------------------------------
59
60 @pytest.fixture()
61 def state_dir(tmp_path: pathlib.Path) -> pathlib.Path:
62 """Minimal social repo state directory."""
63 (tmp_path / "posts").mkdir()
64 (tmp_path / "reactions").mkdir()
65 (tmp_path / "graph" / "follows").mkdir(parents=True)
66 return tmp_path
67
68
69 # ===========================================================================
70 # Shape — command registered, subcommands visible, symbols importable
71 # ===========================================================================
72
73 class TestSocialCliShape:
74
75 def test_social_registered_in_app(self) -> None:
76 code, out, err = invoke_social(["--help"])
77 # --help exits 0 and prints usage; if unregistered it exits non-zero
78 assert code == 0
79
80 def test_help_lists_post_subcommand(self) -> None:
81 _, out, _ = invoke_social(["--help"])
82 assert "post" in out
83
84 def test_help_lists_follow_subcommand(self) -> None:
85 _, out, _ = invoke_social(["--help"])
86 assert "follow" in out
87
88 def test_help_lists_unfollow_subcommand(self) -> None:
89 _, out, _ = invoke_social(["--help"])
90 assert "unfollow" in out
91
92 def test_help_lists_timeline_subcommand(self) -> None:
93 _, out, _ = invoke_social(["--help"])
94 assert "timeline" in out
95
96 def test_help_lists_react_subcommand(self) -> None:
97 _, out, _ = invoke_social(["--help"])
98 assert "react" in out
99
100 def test_help_lists_profile_subcommand(self) -> None:
101 _, out, _ = invoke_social(["--help"])
102 assert "profile" in out
103
104 def test_run_functions_importable(self) -> None:
105 from muse.cli.commands.social import (
106 run_post,
107 run_follow,
108 run_unfollow,
109 run_timeline,
110 run_react,
111 run_profile,
112 )
113 for fn in (run_post, run_follow, run_unfollow, run_timeline, run_react, run_profile):
114 assert callable(fn)
115
116 def test_register_importable(self) -> None:
117 from muse.cli.commands.social import register
118 assert callable(register)
119
120 def test_module_has_docstring(self) -> None:
121 import muse.cli.commands.social as mod
122 assert mod.__doc__ and len(mod.__doc__.strip()) > 20
123
124
125 # ===========================================================================
126 # State helpers — pure logic, no repo, no network
127 # ===========================================================================
128
129 class TestSocialStateHelpers:
130
131 def test_build_post_returns_dict_with_required_keys(self) -> None:
132 from muse.cli.commands.social import _build_post
133 post = _build_post(body="hello muse")
134 assert "body" in post
135 assert "created_at" in post
136 assert "post_id" in post
137
138 def test_build_post_body_stored(self) -> None:
139 from muse.cli.commands.social import _build_post
140 post = _build_post(body="hello muse")
141 assert post["body"] == "hello muse"
142
143 def test_build_post_post_id_is_sha256_prefix(self) -> None:
144 from muse.cli.commands.social import _build_post
145 post = _build_post(body="hello muse")
146 assert post["post_id"].startswith("sha256:")
147
148 def test_build_post_reply_to_stored(self) -> None:
149 from muse.cli.commands.social import _build_post
150 post = _build_post(body="reply text", reply_to="sha256:abc123")
151 assert post["reply_to"] == "sha256:abc123"
152
153 def test_build_post_reply_to_defaults_none(self) -> None:
154 from muse.cli.commands.social import _build_post
155 post = _build_post(body="hello")
156 assert post.get("reply_to") is None
157
158 def test_build_post_deterministic(self) -> None:
159 from muse.cli.commands.social import _build_post
160 p1 = _build_post(body="same text", created_at="2026-05-01T00:00:00Z")
161 p2 = _build_post(body="same text", created_at="2026-05-01T00:00:00Z")
162 assert p1["post_id"] == p2["post_id"]
163
164 def test_write_post_creates_file(
165 self, state_dir: pathlib.Path
166 ) -> None:
167 from muse.cli.commands.social import _build_post, _write_post
168 post = _build_post(body="hello muse", created_at="2026-05-01T00:00:00Z")
169 path = _write_post(state_dir, post)
170 assert path.exists()
171
172 def test_write_post_file_is_valid_json(
173 self, state_dir: pathlib.Path
174 ) -> None:
175 from muse.cli.commands.social import _build_post, _write_post
176 post = _build_post(body="hello muse", created_at="2026-05-01T00:00:00Z")
177 path = _write_post(state_dir, post)
178 data = json.loads(path.read_text())
179 assert data["body"] == "hello muse"
180
181 def test_write_post_file_under_posts_dir(
182 self, state_dir: pathlib.Path
183 ) -> None:
184 from muse.cli.commands.social import _build_post, _write_post
185 post = _build_post(body="hello", created_at="2026-05-01T00:00:00Z")
186 path = _write_post(state_dir, post)
187 assert path.parent.parent.name == "posts"
188
189 def test_write_post_idempotent(self, state_dir: pathlib.Path) -> None:
190 from muse.cli.commands.social import _build_post, _write_post
191 post = _build_post(body="hello", created_at="2026-05-01T00:00:00Z")
192 p1 = _write_post(state_dir, post)
193 p2 = _write_post(state_dir, post)
194 assert p1 == p2
195 assert len(list((state_dir / "posts").iterdir())) == 1
196
197 def test_write_follow_creates_file(self, state_dir: pathlib.Path) -> None:
198 from muse.cli.commands.social import _write_follow
199 path = _write_follow(state_dir, "alice")
200 assert path.exists()
201
202 def test_write_follow_filename_contains_handle(
203 self, state_dir: pathlib.Path
204 ) -> None:
205 from muse.cli.commands.social import _write_follow
206 path = _write_follow(state_dir, "alice")
207 assert "alice" in path.name
208
209 def test_write_follow_content_has_handle(
210 self, state_dir: pathlib.Path
211 ) -> None:
212 from muse.cli.commands.social import _write_follow
213 path = _write_follow(state_dir, "alice")
214 data = json.loads(path.read_text())
215 assert data["handle"] == "alice"
216
217 def test_delete_follow_removes_file(self, state_dir: pathlib.Path) -> None:
218 from muse.cli.commands.social import _write_follow, _delete_follow
219 _write_follow(state_dir, "alice")
220 _delete_follow(state_dir, "alice")
221 assert not (state_dir / "graph" / "follows" / "alice.json").exists()
222
223 def test_delete_follow_nonexistent_is_noop(
224 self, state_dir: pathlib.Path
225 ) -> None:
226 from muse.cli.commands.social import _delete_follow
227 # Should not raise
228 _delete_follow(state_dir, "nobody")
229
230 def test_write_reaction_creates_file(self, state_dir: pathlib.Path) -> None:
231 from muse.cli.commands.social import _write_reaction
232 path = _write_reaction(state_dir, post_id="sha256:aaa", emoji="❤️")
233 assert path.exists()
234
235 def test_write_reaction_content_has_emoji(
236 self, state_dir: pathlib.Path
237 ) -> None:
238 from muse.cli.commands.social import _write_reaction
239 path = _write_reaction(state_dir, post_id="sha256:aaa", emoji="❤️")
240 data = json.loads(path.read_text())
241 assert data["emoji"] == "❤️"
242
243 def test_write_reaction_content_has_post_id(
244 self, state_dir: pathlib.Path
245 ) -> None:
246 from muse.cli.commands.social import _write_reaction
247 path = _write_reaction(state_dir, post_id="sha256:aaa", emoji="❤️")
248 data = json.loads(path.read_text())
249 assert data["post_id"] == "sha256:aaa"
250
251 def test_read_profile_returns_none_when_missing(
252 self, state_dir: pathlib.Path
253 ) -> None:
254 from muse.cli.commands.social import _read_profile
255 assert _read_profile(state_dir) is None
256
257 def test_write_read_profile_roundtrip(self, state_dir: pathlib.Path) -> None:
258 from muse.cli.commands.social import _write_profile, _read_profile
259 _write_profile(state_dir, {"handle": "gabriel", "bio": "building the future"})
260 profile = _read_profile(state_dir)
261 assert profile is not None
262 assert profile["handle"] == "gabriel"
263 assert profile["bio"] == "building the future"
264
265 def test_load_posts_empty_dir(self, state_dir: pathlib.Path) -> None:
266 from muse.cli.commands.social import _load_posts
267 posts = _load_posts(state_dir)
268 assert posts == []
269
270 def test_load_posts_returns_list(self, state_dir: pathlib.Path) -> None:
271 from muse.cli.commands.social import _build_post, _write_post, _load_posts
272 post = _build_post(body="hello", created_at="2026-05-01T00:00:00Z")
273 _write_post(state_dir, post)
274 posts = _load_posts(state_dir)
275 assert len(posts) == 1
276
277 def test_load_posts_sorted_newest_first(self, state_dir: pathlib.Path) -> None:
278 from muse.cli.commands.social import _build_post, _write_post, _load_posts
279 older = _build_post(body="older", created_at="2026-01-01T00:00:00Z")
280 newer = _build_post(body="newer", created_at="2026-06-01T00:00:00Z")
281 _write_post(state_dir, older)
282 _write_post(state_dir, newer)
283 posts = _load_posts(state_dir)
284 assert posts[0]["body"] == "newer"
285 assert posts[1]["body"] == "older"
286
287
288 # ===========================================================================
289 # CLI: post
290 # ===========================================================================
291
292 class TestSocialCliPost:
293
294 def test_post_help_exits_zero(self) -> None:
295 code, _, _ = invoke_social(["post", "--help"])
296 assert code == 0
297
298 def test_post_json_output_has_post_id(self, tmp_path: pathlib.Path) -> None:
299 from muse.cli.commands.social import _build_post, _write_post
300 post = _build_post(body="test post", created_at="2026-05-01T00:00:00Z")
301 (tmp_path / "posts").mkdir()
302 (tmp_path / "reactions").mkdir()
303 (tmp_path / "graph" / "follows").mkdir(parents=True)
304 path = _write_post(tmp_path, post)
305 data = json.loads(path.read_text())
306 assert "post_id" in data
307
308 def test_post_json_has_body(self, tmp_path: pathlib.Path) -> None:
309 from muse.cli.commands.social import _build_post, _write_post
310 post = _build_post(body="hello world", created_at="2026-05-01T00:00:00Z")
311 (tmp_path / "posts").mkdir()
312 path = _write_post(tmp_path, post)
313 data = json.loads(path.read_text())
314 assert data["body"] == "hello world"
315
316 def test_post_with_reply_to_stored(self, tmp_path: pathlib.Path) -> None:
317 from muse.cli.commands.social import _build_post, _write_post
318 post = _build_post(
319 body="great point", reply_to="sha256:abc", created_at="2026-05-01T00:00:00Z"
320 )
321 (tmp_path / "posts").mkdir()
322 path = _write_post(tmp_path, post)
323 data = json.loads(path.read_text())
324 assert data["reply_to"] == "sha256:abc"
325
326 def test_post_missing_body_arg_exits_nonzero(self) -> None:
327 code, _, err = invoke_social(["post"])
328 assert code != 0
329
330
331 # ===========================================================================
332 # CLI: follow / unfollow
333 # ===========================================================================
334
335 class TestSocialCliFollow:
336
337 def test_follow_help_exits_zero(self) -> None:
338 code, _, _ = invoke_social(["follow", "--help"])
339 assert code == 0
340
341 def test_unfollow_help_exits_zero(self) -> None:
342 code, _, _ = invoke_social(["unfollow", "--help"])
343 assert code == 0
344
345 def test_write_follow_idempotent(self, state_dir: pathlib.Path) -> None:
346 from muse.cli.commands.social import _write_follow
347 p1 = _write_follow(state_dir, "alice")
348 p2 = _write_follow(state_dir, "alice")
349 assert p1 == p2
350 assert len(list((state_dir / "graph" / "follows").iterdir())) == 1
351
352 def test_follow_multiple_handles(self, state_dir: pathlib.Path) -> None:
353 from muse.cli.commands.social import _write_follow
354 _write_follow(state_dir, "alice")
355 _write_follow(state_dir, "bob")
356 _write_follow(state_dir, "carol")
357 follows = list((state_dir / "graph" / "follows").iterdir())
358 assert len(follows) == 3
359
360 def test_unfollow_after_follow_removes_file(
361 self, state_dir: pathlib.Path
362 ) -> None:
363 from muse.cli.commands.social import _write_follow, _delete_follow
364 _write_follow(state_dir, "alice")
365 _delete_follow(state_dir, "alice")
366 assert not (state_dir / "graph" / "follows" / "alice.json").exists()
367
368 def test_follow_missing_handle_exits_nonzero(self) -> None:
369 code, _, _ = invoke_social(["follow"])
370 assert code != 0
371
372 def test_unfollow_missing_handle_exits_nonzero(self) -> None:
373 code, _, _ = invoke_social(["unfollow"])
374 assert code != 0
375
376
377 # ===========================================================================
378 # CLI: timeline
379 # ===========================================================================
380
381 class TestSocialCliTimeline:
382
383 def test_timeline_help_exits_zero(self) -> None:
384 code, _, _ = invoke_social(["timeline", "--help"])
385 assert code == 0
386
387 def test_load_posts_empty_returns_empty_list(
388 self, state_dir: pathlib.Path
389 ) -> None:
390 from muse.cli.commands.social import _load_posts
391 assert _load_posts(state_dir) == []
392
393 def test_timeline_sorted_newest_first(
394 self, state_dir: pathlib.Path
395 ) -> None:
396 from muse.cli.commands.social import _build_post, _write_post, _load_posts
397 for i, ts in enumerate(
398 ["2026-01-01T00:00:00Z", "2026-03-01T00:00:00Z", "2026-06-01T00:00:00Z"]
399 ):
400 _write_post(state_dir, _build_post(body=f"post {i}", created_at=ts))
401 posts = _load_posts(state_dir)
402 timestamps = [p["created_at"] for p in posts]
403 assert timestamps == sorted(timestamps, reverse=True)
404
405 def test_timeline_limit(self, state_dir: pathlib.Path) -> None:
406 from muse.cli.commands.social import _build_post, _write_post, _load_posts
407 for i in range(5):
408 _write_post(
409 state_dir,
410 _build_post(body=f"post {i}", created_at=f"2026-0{i+1}-01T00:00:00Z"),
411 )
412 posts = _load_posts(state_dir, limit=3)
413 assert len(posts) == 3
414
415 def test_replies_have_reply_to_field(self, state_dir: pathlib.Path) -> None:
416 from muse.cli.commands.social import _build_post, _write_post, _load_posts
417 root = _build_post(body="root post", created_at="2026-01-01T00:00:00Z")
418 reply = _build_post(
419 body="reply",
420 created_at="2026-01-01T01:00:00Z",
421 reply_to=root["post_id"],
422 )
423 _write_post(state_dir, root)
424 _write_post(state_dir, reply)
425 posts = _load_posts(state_dir)
426 reply_post = next(p for p in posts if p["body"] == "reply")
427 assert reply_post["reply_to"] == root["post_id"]
428
429
430 # ===========================================================================
431 # CLI: react
432 # ===========================================================================
433
434 class TestSocialCliReact:
435
436 def test_react_help_exits_zero(self) -> None:
437 code, _, _ = invoke_social(["react", "--help"])
438 assert code == 0
439
440 def test_react_creates_file_in_reactions_dir(
441 self, state_dir: pathlib.Path
442 ) -> None:
443 from muse.cli.commands.social import _write_reaction
444 _write_reaction(state_dir, post_id="sha256:aaa", emoji="❤️")
445 reactions = list((state_dir / "reactions").iterdir())
446 assert len(reactions) == 1
447
448 def test_react_different_emojis_different_files(
449 self, state_dir: pathlib.Path
450 ) -> None:
451 from muse.cli.commands.social import _write_reaction
452 _write_reaction(state_dir, post_id="sha256:aaa", emoji="❤️")
453 _write_reaction(state_dir, post_id="sha256:aaa", emoji="🔥")
454 reactions = list((state_dir / "reactions").rglob("*.json"))
455 assert len(reactions) == 2
456
457 def test_react_same_emoji_idempotent(self, state_dir: pathlib.Path) -> None:
458 from muse.cli.commands.social import _write_reaction
459 _write_reaction(state_dir, post_id="sha256:aaa", emoji="❤️")
460 _write_reaction(state_dir, post_id="sha256:aaa", emoji="❤️")
461 reactions = list((state_dir / "reactions").rglob("*.json"))
462 assert len(reactions) == 1
463
464 def test_react_missing_args_exits_nonzero(self) -> None:
465 code, _, _ = invoke_social(["react"])
466 assert code != 0
467
468
469 # ===========================================================================
470 # CLI: profile
471 # ===========================================================================
472
473 class TestSocialCliProfile:
474
475 def test_profile_help_exits_zero(self) -> None:
476 code, _, _ = invoke_social(["profile", "--help"])
477 assert code == 0
478
479 def test_write_profile_creates_file(self, state_dir: pathlib.Path) -> None:
480 from muse.cli.commands.social import _write_profile
481 _write_profile(state_dir, {"handle": "gabriel", "bio": "hi"})
482 assert (state_dir / "profile.json").exists()
483
484 def test_write_profile_content_stored(self, state_dir: pathlib.Path) -> None:
485 from muse.cli.commands.social import _write_profile, _read_profile
486 _write_profile(state_dir, {"handle": "gabriel", "bio": "building the future"})
487 profile = _read_profile(state_dir)
488 assert profile["bio"] == "building the future"
489
490 def test_profile_set_updates_bio(self, state_dir: pathlib.Path) -> None:
491 from muse.cli.commands.social import _write_profile, _read_profile
492 _write_profile(state_dir, {"handle": "gabriel", "bio": "old bio"})
493 existing = _read_profile(state_dir)
494 existing["bio"] = "new bio"
495 _write_profile(state_dir, existing)
496 assert _read_profile(state_dir)["bio"] == "new bio"
497
498
499 # ===========================================================================
500 # Docstrings
501 # ===========================================================================
502
503 class TestSocialCliDocstrings:
504
505 def test_module_has_docstring(self) -> None:
506 import muse.cli.commands.social as mod
507 assert mod.__doc__ and len(mod.__doc__.strip()) > 20
508
509 def test_run_functions_have_docstrings(self) -> None:
510 from muse.cli.commands import social
511 for name in ("run_post", "run_follow", "run_unfollow", "run_timeline",
512 "run_react", "run_profile", "register"):
513 fn = getattr(social, name)
514 assert fn.__doc__ and len(fn.__doc__.strip()) > 10, \
515 f"{name}() missing docstring"
516
517 def test_helper_functions_have_docstrings(self) -> None:
518 from muse.cli.commands import social
519 for name in ("_build_post", "_write_post", "_write_follow",
520 "_delete_follow", "_write_reaction", "_read_profile",
521 "_write_profile", "_load_posts"):
522 fn = getattr(social, name)
523 assert fn.__doc__ and len(fn.__doc__.strip()) > 10, \
524 f"{name}() missing docstring"
File History 1 commit
sha256:ff478cfdcdd4b7fd6de89cb68896601a981f945634463275ec333bd20ca36402 Merge branch 'dev' into main Human 20 days ago