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