gabriel / musehub public
test_musehub_profile_snapshot.py python
328 lines 11.1 KB
Raw
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 17 days ago
1 """TDD tests for the profile snapshot pre-computation pipeline.
2
3 Covers:
4 - test_snapshot_table_is_queryable — MusehubProfileSnapshot ORM model works
5 - test_compute_and_persist_snapshot — _compute_and_persist_profile_snapshot writes a row
6 - test_snapshot_is_read_by_profile_route — GET /handle serves data from snapshot (no live queries)
7 - test_stale_snapshot_triggers_fallback — is_stale=True causes live fallback
8 - test_missing_snapshot_triggers_fallback — missing row causes live fallback
9 - test_enqueue_profile_snapshot — enqueue_profile_snapshot inserts a pending job
10 - test_push_enqueues_profile_snapshot — musehub_wire enqueues profile.snapshot on push
11 - test_profile_snapshot_provider_returns_empty — ProfileSnapshotProvider.compute() returns []
12 """
13 from __future__ import annotations
14
15 import json
16 from datetime import datetime, timezone
17
18 import pytest
19 from httpx import AsyncClient
20 from sqlalchemy import select
21 from sqlalchemy.ext.asyncio import AsyncSession
22
23 from muse.core.types import now_utc_iso
24 from musehub.db.musehub_identity_models import MusehubIdentity, MusehubProfileSnapshot
25 from musehub.db.musehub_jobs_models import MusehubBackgroundJob
26 from musehub.db.musehub_repo_models import MusehubRepo
27 from musehub.types.json_types import JSONObject
28
29
30 # ---------------------------------------------------------------------------
31 # Helpers
32 # ---------------------------------------------------------------------------
33
34
35 async def _seed_identity(
36 db: AsyncSession,
37 *,
38 handle: str = "snapuser",
39 user_id: str = "snap-user-001",
40 ) -> MusehubIdentity:
41 identity = MusehubIdentity(
42 identity_id=user_id,
43 handle=handle,
44 identity_type="human",
45 bio="Snapshot test bio",
46 avatar_url=None,
47 )
48 db.add(identity)
49 await db.commit()
50 await db.refresh(identity)
51 return identity
52
53
54 async def _seed_repo(
55 db: AsyncSession,
56 *,
57 owner: str = "snapuser",
58 owner_user_id: str = "snap-user-001",
59 slug: str = "snap-repo",
60 ) -> MusehubRepo:
61 from musehub.core.genesis import compute_repo_id
62 repo_id = compute_repo_id(owner_user_id, slug, "code", now_utc_iso())
63 repo = MusehubRepo(
64 repo_id=repo_id,
65 name=slug,
66 owner=owner,
67 slug=slug,
68 visibility="public",
69 owner_user_id=owner_user_id,
70 )
71 db.add(repo)
72 await db.commit()
73 await db.refresh(repo)
74 return repo
75
76
77 async def _seed_snapshot(
78 db: AsyncSession,
79 *,
80 handle: str = "snapuser",
81 stats: JSONObject | None = None,
82 is_stale: bool = False,
83 ) -> MusehubProfileSnapshot:
84 data = {
85 "stats": stats or {"repo_count": 3, "commit_count": 42, "agent_count": 2, "avg_health": None},
86 "repos": [],
87 "heatmap": {"days": [], "total": 0, "longest_streak": 0, "current_streak": 0},
88 "agent_fleet": [],
89 "badges": [],
90 "footprint": [],
91 "activity_canvas": [],
92 }
93 snap = MusehubProfileSnapshot(
94 handle=handle,
95 data_json=json.dumps(data),
96 computed_at=datetime.now(tz=timezone.utc),
97 is_stale=is_stale,
98 )
99 db.add(snap)
100 await db.commit()
101 await db.refresh(snap)
102 return snap
103
104
105 # ---------------------------------------------------------------------------
106 # Phase 1 — ORM model
107 # ---------------------------------------------------------------------------
108
109
110 async def test_snapshot_table_is_queryable(db_session: AsyncSession) -> None:
111 """MusehubProfileSnapshot ORM model persists and queries correctly."""
112 snap = MusehubProfileSnapshot(
113 handle="tabletest",
114 data_json='{"stats": {"repo_count": 1}}',
115 computed_at=datetime.now(tz=timezone.utc),
116 is_stale=False,
117 )
118 db_session.add(snap)
119 await db_session.commit()
120
121 result = await db_session.execute(
122 select(MusehubProfileSnapshot).where(MusehubProfileSnapshot.handle == "tabletest")
123 )
124 row = result.scalar_one_or_none()
125 assert row is not None
126 assert row.handle == "tabletest"
127 assert row.is_stale is False
128 data = json.loads(row.data_json)
129 assert data["stats"]["repo_count"] == 1
130
131
132 # ---------------------------------------------------------------------------
133 # Phase 2 — ProfileSnapshotProvider
134 # ---------------------------------------------------------------------------
135
136
137 async def test_profile_snapshot_provider_returns_empty(db_session: AsyncSession) -> None:
138 """ProfileSnapshotProvider.compute() always returns [] (writes directly to table)."""
139 from musehub.services.musehub_intel_providers import ProfileSnapshotProvider
140 from musehub.core.genesis import compute_repo_id
141
142 await _seed_identity(db_session)
143 repo = await _seed_repo(db_session)
144
145 provider = ProfileSnapshotProvider()
146 result = await provider.compute(
147 db_session,
148 repo.repo_id,
149 "",
150 {"handle": "snapuser"},
151 )
152 assert result == []
153
154
155 async def test_compute_and_persist_snapshot(db_session: AsyncSession) -> None:
156 """_compute_and_persist_profile_snapshot writes a row to musehub_profile_snapshots."""
157 from musehub.services.musehub_intel_providers import _compute_and_persist_profile_snapshot
158
159 await _seed_identity(db_session)
160 await _seed_repo(db_session)
161
162 await _compute_and_persist_profile_snapshot(db_session, "snapuser")
163 await db_session.commit()
164
165 result = await db_session.execute(
166 select(MusehubProfileSnapshot).where(MusehubProfileSnapshot.handle == "snapuser")
167 )
168 row = result.scalar_one_or_none()
169 assert row is not None
170 assert row.is_stale is False
171 data = json.loads(row.data_json)
172 assert "stats" in data
173 assert "repos" in data
174 assert "heatmap" in data
175 assert "badges" in data
176 assert "activity_canvas" in data
177
178
179 async def test_compute_and_persist_snapshot_upserts(db_session: AsyncSession) -> None:
180 """Re-running _compute_and_persist_profile_snapshot overwrites the existing row."""
181 from musehub.services.musehub_intel_providers import _compute_and_persist_profile_snapshot
182
183 await _seed_identity(db_session)
184 await _seed_repo(db_session)
185
186 # First write
187 await _compute_and_persist_profile_snapshot(db_session, "snapuser")
188 await db_session.commit()
189
190 # Second write — should not raise and should overwrite
191 await _compute_and_persist_profile_snapshot(db_session, "snapuser")
192 await db_session.commit()
193
194 result = await db_session.execute(
195 select(MusehubProfileSnapshot).where(MusehubProfileSnapshot.handle == "snapuser")
196 )
197 rows = result.scalars().all()
198 assert len(rows) == 1 # upsert, not insert
199
200
201 async def test_compute_and_persist_snapshot_missing_identity(db_session: AsyncSession) -> None:
202 """_compute_and_persist_profile_snapshot is a no-op for unknown handles."""
203 from musehub.services.musehub_intel_providers import _compute_and_persist_profile_snapshot
204
205 # No identity seeded — should not raise
206 await _compute_and_persist_profile_snapshot(db_session, "nobody-exists-xyz")
207 await db_session.commit()
208
209 result = await db_session.execute(
210 select(MusehubProfileSnapshot).where(MusehubProfileSnapshot.handle == "nobody-exists-xyz")
211 )
212 assert result.scalar_one_or_none() is None
213
214
215 # ---------------------------------------------------------------------------
216 # Phase 3 — enqueue_profile_snapshot
217 # ---------------------------------------------------------------------------
218
219
220 async def test_enqueue_profile_snapshot(db_session: AsyncSession) -> None:
221 """enqueue_profile_snapshot inserts a pending profile.snapshot job."""
222 from musehub.services.musehub_jobs import enqueue_profile_snapshot
223
224 await _seed_identity(db_session)
225 repo = await _seed_repo(db_session)
226
227 job_id = await enqueue_profile_snapshot(db_session, repo.repo_id, "snapuser")
228 await db_session.commit()
229
230 assert job_id is not None
231
232 result = await db_session.execute(
233 select(MusehubBackgroundJob).where(MusehubBackgroundJob.job_id == job_id)
234 )
235 job = result.scalar_one_or_none()
236 assert job is not None
237 assert job.job_type == "profile.snapshot"
238 assert job.status == "pending"
239 payload = job.payload or {}
240 assert payload.get("handle") == "snapuser"
241
242
243 async def test_enqueue_profile_snapshot_is_idempotent(db_session: AsyncSession) -> None:
244 """enqueue_profile_snapshot returns None if a pending job already exists."""
245 from musehub.services.musehub_jobs import enqueue_profile_snapshot
246
247 await _seed_identity(db_session)
248 repo = await _seed_repo(db_session)
249
250 first = await enqueue_profile_snapshot(db_session, repo.repo_id, "snapuser")
251 await db_session.commit()
252 second = await enqueue_profile_snapshot(db_session, repo.repo_id, "snapuser")
253 await db_session.commit()
254
255 assert first is not None
256 assert second is None # idempotent — no duplicate
257
258
259 # ---------------------------------------------------------------------------
260 # Phase 4 — SSR snapshot fast-path
261 # ---------------------------------------------------------------------------
262
263
264 async def test_snapshot_is_read_by_profile_route(
265 client: AsyncClient,
266 db_session: AsyncSession,
267 ) -> None:
268 """GET /handle serves stats from the pre-computed snapshot when present."""
269 await _seed_identity(db_session, handle="snapuser2", user_id="snap-user-002")
270 await _seed_snapshot(
271 db_session,
272 handle="snapuser2",
273 stats={"repo_count": 99, "commit_count": 777, "agent_count": 5, "avg_health": None},
274 )
275
276 resp = await client.get("/snapuser2?format=json")
277 assert resp.status_code == 200
278 body = resp.json()
279 # The JSON route serialises from the stats dict
280 assert body["repoCount"] == 99
281 assert body["commitCount"] == 777
282
283
284 async def test_stale_snapshot_triggers_fallback(
285 client: AsyncClient,
286 db_session: AsyncSession,
287 ) -> None:
288 """is_stale=True snapshot is ignored; live computation runs instead."""
289 await _seed_identity(db_session, handle="staleuser", user_id="stale-001")
290 await _seed_snapshot(
291 db_session,
292 handle="staleuser",
293 stats={"repo_count": 999, "commit_count": 9999, "agent_count": 0, "avg_health": None},
294 is_stale=True,
295 )
296
297 resp = await client.get("/staleuser?format=json")
298 assert resp.status_code == 200
299 body = resp.json()
300 # Live computation returns 0 repos (no repos seeded), not the stale 999
301 assert body["repoCount"] == 0
302
303
304 async def test_missing_snapshot_falls_back_to_live(
305 client: AsyncClient,
306 db_session: AsyncSession,
307 ) -> None:
308 """When no snapshot exists the route computes live and returns valid data."""
309 await _seed_identity(db_session, handle="nosnapuser", user_id="nosnap-001")
310
311 resp = await client.get("/nosnapuser?format=json")
312 assert resp.status_code == 200
313 body = resp.json()
314 assert body["handle"] == "nosnapuser"
315 assert body["repoCount"] == 0
316
317
318 async def test_snapshot_html_route_serves_200(
319 client: AsyncClient,
320 db_session: AsyncSession,
321 ) -> None:
322 """Profile HTML route works with a pre-computed snapshot."""
323 await _seed_identity(db_session, handle="htmlsnap", user_id="htmlsnap-001")
324 await _seed_snapshot(db_session, handle="htmlsnap")
325
326 resp = await client.get("/htmlsnap")
327 assert resp.status_code == 200
328 assert "text/html" in resp.headers["content-type"]
File History 10 commits
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 17 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 30 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 34 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 35 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 36 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 36 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 48 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 50 days ago