test_musehub_profile_snapshot.py
python
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2
feat: add repair-commit wire endpoint (API parity with repa…
Opus 4.8
minor
⚠ breaking
1 day 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
1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2
feat: add repair-commit wire endpoint (API parity with repa…
Opus 4.8
minor
⚠
1 day ago