gabriel / musehub public
test_social_api.py python
442 lines 16.0 KB
Raw
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor ⚠ breaking 1 day ago
1 """Phase 02 TDD — MuseHub Social API.
2
3 RED → GREEN cycle. Run before implementation to confirm failures, then
4 implement until all pass.
5
6 Coverage matrix
7 ---------------
8 TestSocialApiShape — route registration, 200 for known handle, 404 for unknown
9 TestSocialApiFeed — pagination, posts sorted newest-first, cursor, post fields
10 TestSocialApiStream — SSE endpoint returns text/event-stream, heartbeat
11 TestSocialFanOut — push with domain="social" fans out to subscribers
12 TestSocialApiDocstrings — module, route handler docstrings present
13 """
14 from __future__ import annotations
15
16 import asyncio
17 import json
18 import secrets
19 from collections.abc import AsyncGenerator
20 from datetime import datetime, timezone, timedelta
21
22 import anyio
23 import msgpack
24 import pytest
25 import pytest_asyncio
26 from httpx import AsyncClient, ASGITransport
27 from sqlalchemy.ext.asyncio import AsyncSession
28
29 from musehub.core.genesis import compute_identity_id, compute_repo_id
30 from muse.core.types import blob_id, split_id
31 from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef, MusehubObject, MusehubRepo, MusehubSnapshot, MusehubSnapshotRef
32 from musehub.main import app
33
34
35 # ---------------------------------------------------------------------------
36 # Constants
37 # ---------------------------------------------------------------------------
38
39 _OWNER = "testuser" # matches conftest._TEST_HANDLE
40
41
42 # ---------------------------------------------------------------------------
43 # Helpers
44 # ---------------------------------------------------------------------------
45
46 def _utc_now() -> datetime:
47 return datetime.now(tz=timezone.utc)
48
49
50 def _post_bytes(body: str, created_at: str) -> bytes:
51 return json.dumps({"body": body, "created_at": created_at}).encode()
52
53
54 async def _seed_social_repo(
55 session: AsyncSession,
56 owner: str = _OWNER,
57 posts: list[tuple[str, str]] | None = None,
58 ) -> MusehubRepo:
59 """Create a social-domain repo with an optional set of posts in HEAD.
60
61 Each entry in *posts* is (body, iso_created_at). Posts are seeded as
62 objects stored via content_cache so no real filesystem is needed.
63 """
64 slug = f"social-{secrets.token_hex(4)}"
65 created_at = _utc_now()
66 owner_id = compute_identity_id(owner.encode())
67 repo = MusehubRepo(
68 repo_id=compute_repo_id(owner_id, slug, "social", created_at.isoformat()),
69 name=slug,
70 owner=owner,
71 slug=slug,
72 visibility="public",
73 owner_user_id=owner_id,
74 domain_id="social",
75 created_at=created_at,
76 updated_at=created_at,
77 )
78 session.add(repo)
79 await session.flush()
80 await session.refresh(repo)
81
82 # Build manifest: {path: object_id}
83 manifest: dict[str, str] = {}
84 for body, ts in (posts or []):
85 raw = _post_bytes(body, ts)
86 oid = blob_id(raw)
87 algo, hex_digest = split_id(oid)
88 path = f"posts/{algo}/{hex_digest[:16]}.json"
89 manifest[path] = oid
90 # Seed the object (content_cache so no disk needed)
91 obj = MusehubObject(
92 object_id=oid,
93 path=path,
94 size_bytes=len(raw),
95 storage_uri=None,
96 content_cache=raw,
97 )
98 session.add(obj)
99
100 blob = msgpack.packb(manifest, use_bin_type=True)
101 snap_id = blob_id(blob)
102 snap = MusehubSnapshot(
103 snapshot_id=snap_id,
104 manifest_blob=blob,
105 entry_count=len(manifest),
106 )
107 session.add(snap)
108 session.add(MusehubSnapshotRef(repo_id=repo.repo_id, snapshot_id=snap_id))
109
110 commit_id = blob_id(f"{repo.repo_id}:{snap_id}".encode())
111 commit = MusehubCommit(
112 commit_id=commit_id,
113 branch="main",
114 parent_ids=[],
115 message="initial social commit",
116 author=owner,
117 timestamp=created_at,
118 snapshot_id=snap_id,
119 )
120 session.add(commit)
121 session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=commit_id))
122 await session.flush()
123 await session.commit()
124 return repo
125
126
127 # ---------------------------------------------------------------------------
128 # Fixtures
129 # ---------------------------------------------------------------------------
130
131 @pytest_asyncio.fixture()
132 async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
133 transport = ASGITransport(app=app) # type: ignore[arg-type]
134 async with AsyncClient(transport=transport, base_url="http://test") as ac:
135 yield ac
136
137
138 # ===========================================================================
139 # Shape — route registration
140 # ===========================================================================
141
142 class TestSocialApiShape:
143
144 @pytest.mark.asyncio
145 async def test_feed_404_for_unknown_handle(self, client: AsyncClient) -> None:
146 r = await client.get("/api/social/nobody-exists-xyz")
147 assert r.status_code == 404
148
149 @pytest.mark.asyncio
150 async def test_feed_200_for_known_handle_no_posts(
151 self, client: AsyncClient, db_session: AsyncSession
152 ) -> None:
153 await _seed_social_repo(db_session, posts=[])
154 r = await client.get(f"/api/social/{_OWNER}")
155 assert r.status_code == 200
156
157 @pytest.mark.asyncio
158 async def test_feed_response_has_expected_keys(
159 self, client: AsyncClient, db_session: AsyncSession
160 ) -> None:
161 await _seed_social_repo(db_session, posts=[])
162 r = await client.get(f"/api/social/{_OWNER}")
163 body = r.json()
164 assert "handle" in body
165 assert "posts" in body
166 assert "total" in body
167 assert "next_cursor" in body
168
169 @pytest.mark.asyncio
170 async def test_feed_handle_matches_path(
171 self, client: AsyncClient, db_session: AsyncSession
172 ) -> None:
173 await _seed_social_repo(db_session, posts=[])
174 r = await client.get(f"/api/social/{_OWNER}")
175 assert r.json()["handle"] == _OWNER
176
177 def test_stream_route_registered(self) -> None:
178 # SSE streaming via BaseHTTPMiddleware cannot be consumed in-process;
179 # verify route registration at the app level instead.
180 from musehub.main import app
181 paths = {getattr(r, "path", "") for r in app.routes}
182 assert "/api/social/{handle}/stream" in paths
183
184 def test_stream_returns_streaming_response(self) -> None:
185 import inspect
186 from musehub.api.routes.musehub.social import social_stream
187
188 source = inspect.getsource(social_stream)
189 assert "StreamingResponse" in source
190 assert "SSE_CONTENT_TYPE" in source
191
192
193 # ===========================================================================
194 # Feed — pagination, ordering, post fields
195 # ===========================================================================
196
197 class TestSocialApiFeed:
198
199 @pytest.mark.asyncio
200 async def test_feed_returns_all_posts(
201 self, client: AsyncClient, db_session: AsyncSession
202 ) -> None:
203 posts = [
204 ("hello muse", "2026-05-01T00:00:00Z"),
205 ("second post", "2026-05-01T01:00:00Z"),
206 ("third post", "2026-05-01T02:00:00Z"),
207 ]
208 await _seed_social_repo(db_session, posts=posts)
209 r = await client.get(f"/api/social/{_OWNER}")
210 body = r.json()
211 assert body["total"] == 3
212 assert len(body["posts"]) == 3
213
214 @pytest.mark.asyncio
215 async def test_feed_posts_sorted_newest_first(
216 self, client: AsyncClient, db_session: AsyncSession
217 ) -> None:
218 posts = [
219 ("oldest", "2026-05-01T00:00:00Z"),
220 ("newest", "2026-05-01T02:00:00Z"),
221 ("middle", "2026-05-01T01:00:00Z"),
222 ]
223 await _seed_social_repo(db_session, posts=posts)
224 r = await client.get(f"/api/social/{_OWNER}")
225 returned = [p["body"] for p in r.json()["posts"]]
226 assert returned == ["newest", "middle", "oldest"]
227
228 @pytest.mark.asyncio
229 async def test_feed_post_has_required_fields(
230 self, client: AsyncClient, db_session: AsyncSession
231 ) -> None:
232 posts = [("test post", "2026-05-01T00:00:00Z")]
233 await _seed_social_repo(db_session, posts=posts)
234 r = await client.get(f"/api/social/{_OWNER}")
235 post = r.json()["posts"][0]
236 assert "post_id" in post
237 assert "body" in post
238 assert "created_at" in post
239
240 @pytest.mark.asyncio
241 async def test_feed_limit_parameter(
242 self, client: AsyncClient, db_session: AsyncSession
243 ) -> None:
244 posts = [(f"post {i}", f"2026-05-01T0{i}:00:00Z") for i in range(5)]
245 await _seed_social_repo(db_session, posts=posts)
246 r = await client.get(f"/api/social/{_OWNER}?limit=2")
247 body = r.json()
248 assert len(body["posts"]) == 2
249 assert body["next_cursor"] is not None
250
251 @pytest.mark.asyncio
252 async def test_feed_cursor_pagination(
253 self, client: AsyncClient, db_session: AsyncSession
254 ) -> None:
255 posts = [(f"post {i}", f"2026-05-01T0{i}:00:00Z") for i in range(4)]
256 await _seed_social_repo(db_session, posts=posts)
257 page1 = (await client.get(f"/api/social/{_OWNER}?limit=2")).json()
258 cursor = page1["next_cursor"]
259 assert cursor is not None
260 page2 = (await client.get(f"/api/social/{_OWNER}?limit=2&cursor={cursor}")).json()
261 # Combined posts should cover all 4 with no duplicates
262 all_ids = {p["post_id"] for p in page1["posts"]} | {p["post_id"] for p in page2["posts"]}
263 assert len(all_ids) == 4
264
265 @pytest.mark.asyncio
266 async def test_feed_no_cursor_on_last_page(
267 self, client: AsyncClient, db_session: AsyncSession
268 ) -> None:
269 posts = [("only post", "2026-05-01T00:00:00Z")]
270 await _seed_social_repo(db_session, posts=posts)
271 r = await client.get(f"/api/social/{_OWNER}?limit=10")
272 assert r.json()["next_cursor"] is None
273
274 @pytest.mark.asyncio
275 async def test_feed_empty_for_no_posts(
276 self, client: AsyncClient, db_session: AsyncSession
277 ) -> None:
278 await _seed_social_repo(db_session, posts=[])
279 r = await client.get(f"/api/social/{_OWNER}")
280 body = r.json()
281 assert body["posts"] == []
282 assert body["total"] == 0
283
284
285 # ===========================================================================
286 # Fan-out — push with domain="social" notifies SSE subscribers
287 # ===========================================================================
288
289 class TestSocialFanOut:
290
291 @pytest.mark.asyncio
292 async def test_fan_out_delivers_event_to_subscriber(self) -> None:
293 from musehub.services.musehub_social import (
294 subscribe_handle,
295 unsubscribe_handle,
296 fan_out_to_subscribers,
297 )
298
299 q: asyncio.Queue[dict] = subscribe_handle(_OWNER)
300 try:
301 event = {"type": "social_delta", "posts_added": 1}
302 await fan_out_to_subscribers(_OWNER, event)
303 received = q.get_nowait()
304 assert received["type"] == "social_delta"
305 finally:
306 unsubscribe_handle(_OWNER, q)
307
308 @pytest.mark.asyncio
309 async def test_fan_out_no_subscribers_is_noop(self) -> None:
310 from musehub.services.musehub_social import fan_out_to_subscribers
311
312 # Should not raise even when no subscribers are registered
313 await fan_out_to_subscribers("handle-with-no-subscribers", {"type": "test"})
314
315 @pytest.mark.asyncio
316 async def test_fan_out_multiple_subscribers_all_receive(self) -> None:
317 from musehub.services.musehub_social import (
318 subscribe_handle,
319 unsubscribe_handle,
320 fan_out_to_subscribers,
321 )
322
323 q1: asyncio.Queue[dict] = subscribe_handle(_OWNER)
324 q2: asyncio.Queue[dict] = subscribe_handle(_OWNER)
325 try:
326 await fan_out_to_subscribers(_OWNER, {"type": "social_delta", "n": 1})
327 assert q1.get_nowait()["type"] == "social_delta"
328 assert q2.get_nowait()["type"] == "social_delta"
329 finally:
330 unsubscribe_handle(_OWNER, q1)
331 unsubscribe_handle(_OWNER, q2)
332
333 @pytest.mark.asyncio
334 async def test_unsubscribe_stops_delivery(self) -> None:
335 from musehub.services.musehub_social import (
336 subscribe_handle,
337 unsubscribe_handle,
338 fan_out_to_subscribers,
339 )
340
341 q: asyncio.Queue[dict] = subscribe_handle(_OWNER)
342 unsubscribe_handle(_OWNER, q)
343 await fan_out_to_subscribers(_OWNER, {"type": "social_delta"})
344 assert q.empty()
345
346
347 # ===========================================================================
348 # Service layer — get_social_feed unit tests
349 # ===========================================================================
350
351 class TestSocialFeedService:
352
353 @pytest.mark.asyncio
354 async def test_get_social_feed_empty_when_no_social_repo(
355 self, db_session: AsyncSession
356 ) -> None:
357 from musehub.services.musehub_social import get_social_feed
358
359 result = await get_social_feed(db_session, "nonexistent-handle-abc")
360 assert result["posts"] == []
361 assert result["total"] == 0
362
363 @pytest.mark.asyncio
364 async def test_get_social_feed_returns_posts(
365 self, db_session: AsyncSession
366 ) -> None:
367 from musehub.services.musehub_social import get_social_feed
368
369 posts = [
370 ("hello service", "2026-05-01T00:00:00Z"),
371 ("second", "2026-05-01T01:00:00Z"),
372 ]
373 await _seed_social_repo(db_session, posts=posts)
374 result = await get_social_feed(db_session, _OWNER)
375 assert result["total"] == 2
376
377 @pytest.mark.asyncio
378 async def test_get_social_feed_sorted_newest_first(
379 self, db_session: AsyncSession
380 ) -> None:
381 from musehub.services.musehub_social import get_social_feed
382
383 posts = [
384 ("old", "2026-05-01T00:00:00Z"),
385 ("new", "2026-05-01T02:00:00Z"),
386 ]
387 await _seed_social_repo(db_session, posts=posts)
388 result = await get_social_feed(db_session, _OWNER)
389 assert result["posts"][0]["body"] == "new"
390
391 @pytest.mark.asyncio
392 async def test_get_social_feed_limit(
393 self, db_session: AsyncSession
394 ) -> None:
395 from musehub.services.musehub_social import get_social_feed
396
397 posts = [(f"post{i}", f"2026-05-01T0{i}:00:00Z") for i in range(5)]
398 await _seed_social_repo(db_session, posts=posts)
399 result = await get_social_feed(db_session, _OWNER, limit=2)
400 assert len(result["posts"]) == 2
401 assert result["next_cursor"] is not None
402
403 @pytest.mark.asyncio
404 async def test_get_social_feed_raises_404_not_found(
405 self, db_session: AsyncSession
406 ) -> None:
407 from musehub.services.musehub_social import get_social_feed
408
409 # No social repo for this handle → should return empty, not raise
410 result = await get_social_feed(db_session, "nobody-xyz-abc")
411 assert result["posts"] == []
412
413
414 # ===========================================================================
415 # Docstrings
416 # ===========================================================================
417
418 class TestSocialApiDocstrings:
419
420 def test_route_module_has_docstring(self) -> None:
421 import musehub.api.routes.musehub.social as mod
422 assert mod.__doc__ and len(mod.__doc__.strip()) > 20
423
424 def test_service_module_has_docstring(self) -> None:
425 import musehub.services.musehub_social as mod
426 assert mod.__doc__ and len(mod.__doc__.strip()) > 20
427
428 def test_feed_handler_has_docstring(self) -> None:
429 from musehub.api.routes.musehub.social import social_feed
430 assert social_feed.__doc__ and len(social_feed.__doc__.strip()) > 10
431
432 def test_stream_handler_has_docstring(self) -> None:
433 from musehub.api.routes.musehub.social import social_stream
434 assert social_stream.__doc__ and len(social_stream.__doc__.strip()) > 10
435
436 def test_get_social_feed_service_has_docstring(self) -> None:
437 from musehub.services.musehub_social import get_social_feed
438 assert get_social_feed.__doc__ and len(get_social_feed.__doc__.strip()) > 10
439
440 def test_fan_out_has_docstring(self) -> None:
441 from musehub.services.musehub_social import fan_out_to_subscribers
442 assert fan_out_to_subscribers.__doc__ and len(fan_out_to_subscribers.__doc__.strip()) > 10
File History 1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor 1 day ago