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