gabriel / musehub public
test_repo_card_enrichment_integration.py python
395 lines 15.1 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago
1 """
2 Tier 2 — Integration tests for enrich_repo_cards() against a real test database.
3
4 These tests exercise the full service call — SQL queries run against the test
5 postgres instance populated via factory helpers and direct ORM inserts.
6
7 Test IDs
8 --------
9 T200 — repo with commits on every day of the 30-day window gets correct daily counts
10 T201 — autonomy_pct is 100 when all commits carry a non-empty agent_id
11 T202 — autonomy_pct is 0 when no commits carry an agent_id
12 T203 — autonomy_pct is rounded correctly for a mixed repo (e.g. 3/4 = 75%)
13 T204 — hottest_symbol matches the symbol with the highest churn_30d
14 T205 — blast_leader matches the symbol with the highest blast score
15 T206 — dead_count counts only high-confidence dead symbols (medium/low excluded)
16 T207 — health_status is 'risk' when breakage_meta has error_count > 0
17 T208 — health_status is 'warn' when dead_count > 0, error_count == 0
18 T209 — health_status is 'clean' when no dead symbols and no breakage meta row
19 T210 — enrich_repo_cards batches two repos correctly in a single call
20 T211 — repos with no intel rows return safe zero-value enrichment (no crash)
21 T212 — pulse_buckets always has exactly 30 entries regardless of commit pattern
22 T213 — commits older than 30 days do not appear in pulse_buckets
23 T214 — hottest_symbol is None when symbol_intel has no rows for the repo
24 T215 — blast_leader is None when all blast scores are zero
25 """
26 from __future__ import annotations
27
28 import secrets
29 from datetime import datetime, timedelta, timezone
30
31 import pytest
32 import pytest_asyncio
33 from sqlalchemy.ext.asyncio import AsyncSession
34
35 from musehub.db.musehub_models import (
36 MusehubCommit,
37 MusehubIntelBreakageMeta,
38 MusehubIntelDead,
39 MusehubSymbolIntel,
40 )
41 from musehub.services.repo_card_enrichment import (
42 _PULSE_DAYS,
43 enrich_repo_cards,
44 )
45 from tests.factories import create_commit, create_repo
46
47 # ---------------------------------------------------------------------------
48 # Helpers
49 # ---------------------------------------------------------------------------
50
51 def _utc_now() -> datetime:
52 return datetime.now(tz=timezone.utc)
53
54
55 def _days_ago(n: int) -> datetime:
56 return _utc_now() - timedelta(days=n)
57
58
59 def _commit_id() -> str:
60 return f"sha256:{secrets.token_hex(32)}"
61
62
63 async def _insert_symbol_intel(
64 session: AsyncSession,
65 repo_id: str,
66 address: str,
67 churn_30d: int = 0,
68 blast: int = 0,
69 ) -> MusehubSymbolIntel:
70 """Insert a MusehubSymbolIntel row and commit."""
71 row = MusehubSymbolIntel(
72 repo_id=repo_id,
73 address=address,
74 churn_30d=churn_30d,
75 blast=blast,
76 )
77 session.add(row)
78 await session.commit()
79 return row
80
81
82 async def _insert_dead(
83 session: AsyncSession,
84 repo_id: str,
85 address: str,
86 confidence: str = "high",
87 ) -> MusehubIntelDead:
88 """Insert a MusehubIntelDead row and commit."""
89 row = MusehubIntelDead(
90 repo_id=repo_id,
91 address=address,
92 kind="function",
93 confidence=confidence,
94 ref="main",
95 )
96 session.add(row)
97 await session.commit()
98 return row
99
100
101 async def _insert_breakage_meta(
102 session: AsyncSession,
103 repo_id: str,
104 error_count: int = 0,
105 warning_count: int = 0,
106 ) -> MusehubIntelBreakageMeta:
107 """Insert a MusehubIntelBreakageMeta row and commit."""
108 row = MusehubIntelBreakageMeta(
109 repo_id=repo_id,
110 total_issues=error_count + warning_count,
111 error_count=error_count,
112 warning_count=warning_count,
113 file_count=1,
114 ref="main",
115 )
116 session.add(row)
117 await session.commit()
118 return row
119
120
121 async def _add_agent_commit(
122 session: AsyncSession,
123 repo_id: str,
124 timestamp: datetime | None = None,
125 agent_id: str = "claude-code",
126 ) -> MusehubCommit:
127 """Create a commit with a non-empty agent_id (agent commit)."""
128 commit = await create_commit(session, repo_id, timestamp=timestamp or _utc_now())
129 # MusehubCommit.agent_id is not in CommitFactory; set it directly via update
130 from sqlalchemy import text
131 await session.execute(
132 text("UPDATE musehub_commits SET agent_id = :aid WHERE commit_id = :cid"),
133 {"aid": agent_id, "cid": commit.commit_id},
134 )
135 await session.commit()
136 return commit
137
138
139 async def _add_human_commit(
140 session: AsyncSession,
141 repo_id: str,
142 timestamp: datetime | None = None,
143 ) -> MusehubCommit:
144 """Create a commit with an empty agent_id (human commit)."""
145 return await create_commit(session, repo_id, timestamp=timestamp or _utc_now())
146
147
148 # ---------------------------------------------------------------------------
149 # T200: correct daily pulse counts
150 # ---------------------------------------------------------------------------
151
152 @pytest.mark.asyncio
153 async def test_t200_pulse_correct_daily_counts(db_session: AsyncSession):
154 """T200: commits on known days produce the correct count in pulse_buckets."""
155 repo = await create_repo(db_session, visibility="public")
156 today = _utc_now().replace(hour=12, minute=0, second=0, microsecond=0)
157
158 # 3 commits today, 2 commits yesterday
159 for _ in range(3):
160 await create_commit(db_session, repo.repo_id, timestamp=today)
161 for _ in range(2):
162 await create_commit(db_session, repo.repo_id, timestamp=today - timedelta(days=1))
163
164 result = await enrich_repo_cards(db_session, [repo.repo_id])
165 enc = result[repo.repo_id]
166
167 today_bucket = next(b for b in enc.pulse_buckets if b.date == today.date().isoformat())
168 yesterday_bucket = next(
169 b for b in enc.pulse_buckets
170 if b.date == (today - timedelta(days=1)).date().isoformat()
171 )
172
173 assert today_bucket.count == 3
174 assert yesterday_bucket.count == 2
175
176
177 # ---------------------------------------------------------------------------
178 # T201–T203: autonomy_pct
179 # ---------------------------------------------------------------------------
180
181 @pytest.mark.asyncio
182 async def test_t201_autonomy_pct_100_all_agent(db_session: AsyncSession):
183 """T201: autonomy_pct is 100 when every commit has a non-empty agent_id."""
184 repo = await create_repo(db_session, visibility="public")
185 for _ in range(4):
186 await _add_agent_commit(db_session, repo.repo_id)
187
188 result = await enrich_repo_cards(db_session, [repo.repo_id])
189 assert result[repo.repo_id].autonomy_pct == 100
190
191
192 @pytest.mark.asyncio
193 async def test_t202_autonomy_pct_0_all_human(db_session: AsyncSession):
194 """T202: autonomy_pct is 0 when no commits have an agent_id set."""
195 repo = await create_repo(db_session, visibility="public")
196 for _ in range(3):
197 await _add_human_commit(db_session, repo.repo_id)
198
199 result = await enrich_repo_cards(db_session, [repo.repo_id])
200 assert result[repo.repo_id].autonomy_pct == 0
201
202
203 @pytest.mark.asyncio
204 async def test_t203_autonomy_pct_mixed(db_session: AsyncSession):
205 """T203: autonomy_pct rounds correctly for a 3-agent / 1-human repo (75%)."""
206 repo = await create_repo(db_session, visibility="public")
207 for _ in range(3):
208 await _add_agent_commit(db_session, repo.repo_id)
209 await _add_human_commit(db_session, repo.repo_id)
210
211 result = await enrich_repo_cards(db_session, [repo.repo_id])
212 assert result[repo.repo_id].autonomy_pct == 75
213
214
215 # ---------------------------------------------------------------------------
216 # T204–T205: hottest_symbol and blast_leader
217 # ---------------------------------------------------------------------------
218
219 @pytest.mark.asyncio
220 async def test_t204_hottest_symbol_highest_churn(db_session: AsyncSession):
221 """T204: hottest_symbol is the symbol with the highest churn_30d."""
222 repo = await create_repo(db_session, visibility="public")
223 await _insert_symbol_intel(db_session, repo.repo_id, "src/a.py::slow_fn", churn_30d=2)
224 await _insert_symbol_intel(db_session, repo.repo_id, "src/b.py::hot_fn", churn_30d=9)
225 await _insert_symbol_intel(db_session, repo.repo_id, "src/c.py::mid_fn", churn_30d=5)
226
227 result = await enrich_repo_cards(db_session, [repo.repo_id])
228 enc = result[repo.repo_id]
229
230 assert enc.hottest_symbol is not None
231 assert enc.hottest_symbol.address == "src/b.py::hot_fn"
232 assert enc.hottest_symbol.churn_30d == 9
233
234
235 @pytest.mark.asyncio
236 async def test_t205_blast_leader_highest_blast(db_session: AsyncSession):
237 """T205: blast_leader is the symbol with the highest blast score."""
238 repo = await create_repo(db_session, visibility="public")
239 await _insert_symbol_intel(db_session, repo.repo_id, "src/a.py::small", blast=10)
240 await _insert_symbol_intel(db_session, repo.repo_id, "src/b.py::large", blast=847)
241
242 result = await enrich_repo_cards(db_session, [repo.repo_id])
243 enc = result[repo.repo_id]
244
245 assert enc.blast_leader is not None
246 assert enc.blast_leader.address == "src/b.py::large"
247 assert enc.blast_leader.blast == 847
248
249
250 # ---------------------------------------------------------------------------
251 # T206: dead_count confidence filtering
252 # ---------------------------------------------------------------------------
253
254 @pytest.mark.asyncio
255 async def test_t206_dead_count_only_high_confidence(db_session: AsyncSession):
256 """T206: dead_count excludes medium and low confidence dead symbols."""
257 repo = await create_repo(db_session, visibility="public")
258 await _insert_dead(db_session, repo.repo_id, "src/a.py::fn_high", confidence="high")
259 await _insert_dead(db_session, repo.repo_id, "src/b.py::fn_medium", confidence="medium")
260 await _insert_dead(db_session, repo.repo_id, "src/c.py::fn_low", confidence="low")
261
262 result = await enrich_repo_cards(db_session, [repo.repo_id])
263 assert result[repo.repo_id].dead_count == 1
264
265
266 # ---------------------------------------------------------------------------
267 # T207–T209: health_status via breakage + dead data
268 # ---------------------------------------------------------------------------
269
270 @pytest.mark.asyncio
271 async def test_t207_health_risk_when_breakage_errors(db_session: AsyncSession):
272 """T207: health_status is 'risk' when breakage_meta has error_count > 0."""
273 repo = await create_repo(db_session, visibility="public")
274 await _insert_breakage_meta(db_session, repo.repo_id, error_count=2, warning_count=1)
275
276 result = await enrich_repo_cards(db_session, [repo.repo_id])
277 assert result[repo.repo_id].health_status == "risk"
278
279
280 @pytest.mark.asyncio
281 async def test_t208_health_warn_when_dead_no_errors(db_session: AsyncSession):
282 """T208: health_status is 'warn' when dead symbols exist but no errors."""
283 repo = await create_repo(db_session, visibility="public")
284 await _insert_dead(db_session, repo.repo_id, "src/a.py::old_fn", confidence="high")
285
286 result = await enrich_repo_cards(db_session, [repo.repo_id])
287 assert result[repo.repo_id].health_status == "warn"
288
289
290 @pytest.mark.asyncio
291 async def test_t209_health_clean_no_data(db_session: AsyncSession):
292 """T209: health_status is 'clean' when intel tables have no rows for repo."""
293 repo = await create_repo(db_session, visibility="public")
294
295 result = await enrich_repo_cards(db_session, [repo.repo_id])
296 assert result[repo.repo_id].health_status == "clean"
297
298
299 # ---------------------------------------------------------------------------
300 # T210: batching multiple repos
301 # ---------------------------------------------------------------------------
302
303 @pytest.mark.asyncio
304 async def test_t210_batches_multiple_repos(db_session: AsyncSession):
305 """T210: enrich_repo_cards correctly enriches two repos in one call."""
306 repo_a = await create_repo(db_session, visibility="public")
307 repo_b = await create_repo(db_session, visibility="public")
308
309 await _add_agent_commit(db_session, repo_a.repo_id)
310 await _insert_dead(db_session, repo_b.repo_id, "src/b.py::fn", confidence="high")
311
312 result = await enrich_repo_cards(db_session, [repo_a.repo_id, repo_b.repo_id])
313
314 assert result[repo_a.repo_id].autonomy_pct == 100
315 assert result[repo_b.repo_id].dead_count == 1
316 # cross-repo isolation
317 assert result[repo_a.repo_id].dead_count == 0
318 assert result[repo_b.repo_id].autonomy_pct == 0
319
320
321 # ---------------------------------------------------------------------------
322 # T211: safe zero-value for repos with no intel data
323 # ---------------------------------------------------------------------------
324
325 @pytest.mark.asyncio
326 async def test_t211_safe_zero_value_no_intel(db_session: AsyncSession):
327 """T211: a repo with no intel rows returns a zero-value enrichment without crashing."""
328 repo = await create_repo(db_session, visibility="public")
329
330 result = await enrich_repo_cards(db_session, [repo.repo_id])
331 enc = result[repo.repo_id]
332
333 assert enc.autonomy_pct == 0
334 assert enc.hottest_symbol is None
335 assert enc.blast_leader is None
336 assert enc.dead_count == 0
337 assert enc.error_count == 0
338 assert enc.warning_count == 0
339 assert len(enc.pulse_buckets) == _PULSE_DAYS
340
341
342 # ---------------------------------------------------------------------------
343 # T212: pulse always 30 buckets
344 # ---------------------------------------------------------------------------
345
346 @pytest.mark.asyncio
347 async def test_t212_pulse_always_30_buckets(db_session: AsyncSession):
348 """T212: pulse_buckets always has exactly 30 entries regardless of commit pattern."""
349 repo = await create_repo(db_session, visibility="public")
350 # Scatter commits across random days in the window
351 for n in [0, 5, 10, 15, 20, 25]:
352 await create_commit(db_session, repo.repo_id, timestamp=_days_ago(n))
353
354 result = await enrich_repo_cards(db_session, [repo.repo_id])
355 assert len(result[repo.repo_id].pulse_buckets) == _PULSE_DAYS
356
357
358 # ---------------------------------------------------------------------------
359 # T213: old commits excluded from pulse
360 # ---------------------------------------------------------------------------
361
362 @pytest.mark.asyncio
363 async def test_t213_commits_older_than_30d_excluded_from_pulse(db_session: AsyncSession):
364 """T213: commits older than 30 days do not appear in pulse_buckets."""
365 repo = await create_repo(db_session, visibility="public")
366 await create_commit(db_session, repo.repo_id, timestamp=_days_ago(31))
367 await create_commit(db_session, repo.repo_id, timestamp=_days_ago(60))
368
369 result = await enrich_repo_cards(db_session, [repo.repo_id])
370 enc = result[repo.repo_id]
371 total_counted = sum(b.count for b in enc.pulse_buckets)
372 assert total_counted == 0
373
374
375 # ---------------------------------------------------------------------------
376 # T214–T215: None when no qualifying intel rows
377 # ---------------------------------------------------------------------------
378
379 @pytest.mark.asyncio
380 async def test_t214_hottest_symbol_none_when_no_symbol_intel(db_session: AsyncSession):
381 """T214: hottest_symbol is None when musehub_symbol_intel has no rows for repo."""
382 repo = await create_repo(db_session, visibility="public")
383
384 result = await enrich_repo_cards(db_session, [repo.repo_id])
385 assert result[repo.repo_id].hottest_symbol is None
386
387
388 @pytest.mark.asyncio
389 async def test_t215_blast_leader_none_when_all_blast_zero(db_session: AsyncSession):
390 """T215: blast_leader is None when all blast scores are zero."""
391 repo = await create_repo(db_session, visibility="public")
392 await _insert_symbol_intel(db_session, repo.repo_id, "src/a.py::fn", churn_30d=5, blast=0)
393
394 result = await enrich_repo_cards(db_session, [repo.repo_id])
395 assert result[repo.repo_id].blast_leader is None
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago