gabriel / musehub public
test_velocity_provider.py python
934 lines 40.4 KB
Raw
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ breaking 142 days ago
1 """TDD spec for VelocityProvider — issue #16, Phase 5.
2
3 Verifies that VelocityProvider reproduces module growth velocity from the
4 symbol history store without subprocess calls: module derivation from symbol
5 addresses, op categorisation (add/delete/modify), two-window BFS analysis
6 (current vs prior), acceleration, stagnant-commit detection, extended columns
7 (prior_modified, prior_active_commits, window_size, commits_analysed), TOP cap,
8 and strict repo isolation.
9
10 Seven test tiers (50 cases)
11 ----------------------------
12 Unit VL_01 – VL_08 module derivation, accel helpers, constants
13 Integration VL_09 – VL_18 provider upserts, new columns, op categorisation
14 E2E VL_19 – VL_25 full seeded scenarios, window semantics
15 Performance VL_26 – VL_32 timing bounds
16 State VL_33 – VL_38 idempotency, stale-row purge, incremental updates
17 Security VL_39 – VL_44 injection strings, repo isolation, unicode
18 Stress VL_45 – VL_50 TOP cap, BFS cap, extended-column completeness
19 """
20 from __future__ import annotations
21
22 import secrets
23 import time
24 from datetime import datetime, timezone
25
26 import pytest
27 import pytest_asyncio
28 import sqlalchemy as sa
29 from sqlalchemy.dialects.postgresql import insert as pg_insert
30 from sqlalchemy.ext.asyncio import AsyncSession
31
32 from musehub.db import musehub_models as db
33 from musehub.services.musehub_intel_providers import VelocityProvider
34 from musehub.api.routes.musehub.ui_intel import _vel_accel_class, _vel_accel_fmt
35 from tests.factories import create_repo
36
37
38 # ─────────────────────────────────────────────────────────────────────────────
39 # Helpers
40 # ─────────────────────────────────────────────────────────────────────────────
41
42 def _cid() -> str:
43 return "sha256:" + secrets.token_hex(32)
44
45
46 async def _seed_commit(
47 session: AsyncSession,
48 repo_id: str,
49 commit_id: str,
50 parent_ids: list[str] | None = None,
51 ) -> None:
52 """Insert a commit row; silently skip on conflict."""
53 await session.execute(
54 pg_insert(db.MusehubCommit)
55 .values(
56 commit_id=commit_id,
57 repo_id=repo_id,
58 message="test commit",
59 author="test",
60 branch="dev",
61 parent_ids=parent_ids or [],
62 snapshot_id=None,
63 timestamp=datetime.now(timezone.utc),
64 )
65 .on_conflict_do_nothing()
66 )
67
68
69 async def _seed_history(
70 session: AsyncSession,
71 repo_id: str,
72 commit_id: str,
73 addresses: list[str],
74 op: str = "modify",
75 ) -> None:
76 """Insert symbol history entries with a given op code."""
77 for addr in addresses:
78 await session.execute(
79 pg_insert(db.MusehubSymbolHistoryEntry)
80 .values(
81 repo_id=repo_id,
82 address=addr,
83 commit_id=commit_id,
84 committed_at=datetime.now(timezone.utc),
85 op=op,
86 )
87 .on_conflict_do_nothing()
88 )
89
90
91 async def _run(session: AsyncSession, repo_id: str, ref: str) -> list:
92 return await VelocityProvider().compute(session, repo_id, ref, {})
93
94
95 async def _fetch(session: AsyncSession, repo_id: str) -> list[db.MusehubIntelVelocity]:
96 result = await session.execute(
97 sa.select(db.MusehubIntelVelocity)
98 .where(db.MusehubIntelVelocity.repo_id == repo_id)
99 .order_by(sa.desc(db.MusehubIntelVelocity.active_commits))
100 )
101 return list(result.scalars().all())
102
103
104 def _module(addr: str) -> str:
105 """Replicate VelocityProvider._module() for unit tests."""
106 file = addr.split("::")[0] if "::" in addr else addr
107 if "/" in file:
108 return file.rsplit("/", 1)[0] + "/"
109 return file + "/"
110
111
112 # ─────────────────────────────────────────────────────────────────────────────
113 # Fixtures
114 # ─────────────────────────────────────────────────────────────────────────────
115
116 @pytest_asyncio.fixture
117 async def repo(db_session: AsyncSession):
118 return await create_repo(db_session, owner="testuser", slug="velocityprovider")
119
120
121 @pytest_asyncio.fixture
122 async def two_repos(db_session: AsyncSession):
123 r1 = await create_repo(db_session, owner="testuser", slug="vel-repo-1")
124 r2 = await create_repo(db_session, owner="testuser", slug="vel-repo-2")
125 return r1, r2
126
127
128 # ─────────────────────────────────────────────────────────────────────────────
129 # Tier 1 — Unit: module derivation, accel helpers, constants
130 # ─────────────────────────────────────────────────────────────────────────────
131
132 class TestVelocityUnit:
133 """Pure-function tests — no database required."""
134
135 def test_VL_01_module_from_deep_symbol_address(self) -> None:
136 """Module extracted as directory of file component of a deep address."""
137 assert _module("musehub/services/musehub_wire.py::MyClass") == "musehub/services/"
138
139 def test_VL_02_module_from_shallow_symbol_address(self) -> None:
140 """Shallow one-directory file extracts its directory."""
141 assert _module("src/billing.py::charge") == "src/"
142
143 def test_VL_03_module_from_bare_file_no_slash(self) -> None:
144 """Root-level file (no '/') maps to '<filename>/'."""
145 assert _module("billing.py") == "billing.py/"
146
147 def test_VL_04_module_from_bare_path_with_slash(self) -> None:
148 """Bare path with slash (no '::') derives module correctly."""
149 assert _module("musehub/services/foo.py") == "musehub/services/"
150
151 def test_VL_05_accel_class_positive(self) -> None:
152 """Positive acceleration → 'up' class."""
153 assert _vel_accel_class(5.0) == "up"
154 assert _vel_accel_class(0.1) == "up"
155
156 def test_VL_06_accel_class_negative(self) -> None:
157 """Negative acceleration → 'down' class."""
158 assert _vel_accel_class(-3.0) == "down"
159 assert _vel_accel_class(-0.1) == "down"
160
161 def test_VL_07_accel_class_zero(self) -> None:
162 """Zero acceleration → 'flat' class."""
163 assert _vel_accel_class(0.0) == "flat"
164
165 def test_VL_08_accel_fmt_positive_negative_zero(self) -> None:
166 """accel_fmt prefixes '+' for positive, keeps '-' for negative, '0' for zero."""
167 assert _vel_accel_fmt(4.0) == "+4"
168 assert _vel_accel_fmt(-3.0) == "-3"
169 assert _vel_accel_fmt(0.0) == "0"
170
171
172 # ─────────────────────────────────────────────────────────────────────────────
173 # Tier 2 — Integration: provider upserts, op categorisation, new columns
174 # ─────────────────────────────────────────────────────────────────────────────
175
176 class TestVelocityIntegration:
177
178 @pytest.mark.asyncio
179 async def test_VL_09_empty_repo_returns_empty(
180 self, db_session: AsyncSession, repo
181 ) -> None:
182 """Provider on a repo with no commits returns [] and stores no rows."""
183 result = await _run(db_session, repo.repo_id, _cid())
184 assert result == []
185 assert await _fetch(db_session, repo.repo_id) == []
186
187 @pytest.mark.asyncio
188 async def test_VL_10_no_history_entries_returns_empty(
189 self, db_session: AsyncSession, repo
190 ) -> None:
191 """Commits exist but no history entries → no rows stored."""
192 c1 = _cid()
193 await _seed_commit(db_session, repo.repo_id, c1)
194 await db_session.commit()
195 result = await _run(db_session, repo.repo_id, c1)
196 assert result == []
197
198 @pytest.mark.asyncio
199 async def test_VL_11_add_op_counted_as_added(
200 self, db_session: AsyncSession, repo
201 ) -> None:
202 """History entries with op='add' increment the added counter."""
203 c1 = _cid()
204 await _seed_commit(db_session, repo.repo_id, c1)
205 await _seed_history(db_session, repo.repo_id, c1,
206 ["src/billing.py::charge"], op="add")
207 await db_session.commit()
208 await _run(db_session, repo.repo_id, c1)
209 rows = await _fetch(db_session, repo.repo_id)
210 assert len(rows) == 1
211 assert rows[0].added == 1
212 assert rows[0].removed == 0
213 assert rows[0].modified == 0
214
215 @pytest.mark.asyncio
216 async def test_VL_12_delete_op_counted_as_removed(
217 self, db_session: AsyncSession, repo
218 ) -> None:
219 """History entries with op='delete' increment the removed counter."""
220 c1 = _cid()
221 await _seed_commit(db_session, repo.repo_id, c1)
222 await _seed_history(db_session, repo.repo_id, c1,
223 ["src/billing.py::charge"], op="delete")
224 await db_session.commit()
225 await _run(db_session, repo.repo_id, c1)
226 rows = await _fetch(db_session, repo.repo_id)
227 assert rows[0].removed == 1
228 assert rows[0].added == 0
229
230 @pytest.mark.asyncio
231 async def test_VL_13_modify_op_counted_as_modified(
232 self, db_session: AsyncSession, repo
233 ) -> None:
234 """History entries with op='modify' increment the modified counter."""
235 c1 = _cid()
236 await _seed_commit(db_session, repo.repo_id, c1)
237 await _seed_history(db_session, repo.repo_id, c1,
238 ["src/billing.py::charge"], op="modify")
239 await db_session.commit()
240 await _run(db_session, repo.repo_id, c1)
241 rows = await _fetch(db_session, repo.repo_id)
242 assert rows[0].modified == 1
243 assert rows[0].added == 0
244
245 @pytest.mark.asyncio
246 async def test_VL_14_net_equals_added_minus_removed(
247 self, db_session: AsyncSession, repo
248 ) -> None:
249 """net = added - removed for the current window."""
250 c1 = _cid()
251 await _seed_commit(db_session, repo.repo_id, c1)
252 await _seed_history(db_session, repo.repo_id, c1,
253 ["src/billing.py::a", "src/billing.py::b"], op="add")
254 await _seed_history(db_session, repo.repo_id, c1,
255 ["src/billing.py::c"], op="delete")
256 await db_session.commit()
257 await _run(db_session, repo.repo_id, c1)
258 rows = await _fetch(db_session, repo.repo_id)
259 assert rows[0].net == rows[0].added - rows[0].removed
260
261 @pytest.mark.asyncio
262 async def test_VL_15_active_commits_counts_distinct_commits(
263 self, db_session: AsyncSession, repo
264 ) -> None:
265 """active_commits equals the number of distinct commits that touched the module."""
266 commits = [_cid() for _ in range(3)]
267 prev = None
268 for cid in commits:
269 await _seed_commit(db_session, repo.repo_id, cid,
270 [prev] if prev else [])
271 prev = cid
272 for cid in commits:
273 await _seed_history(db_session, repo.repo_id, cid,
274 ["src/billing.py::fn"])
275 await db_session.commit()
276 await _run(db_session, repo.repo_id, commits[-1])
277 rows = await _fetch(db_session, repo.repo_id)
278 assert rows[0].active_commits == 3
279
280 @pytest.mark.asyncio
281 async def test_VL_16_window_size_column_populated(
282 self, db_session: AsyncSession, repo
283 ) -> None:
284 """window_size column reflects VelocityProvider._WINDOW."""
285 c1 = _cid()
286 await _seed_commit(db_session, repo.repo_id, c1)
287 await _seed_history(db_session, repo.repo_id, c1,
288 ["src/billing.py::fn"])
289 await db_session.commit()
290 await _run(db_session, repo.repo_id, c1)
291 rows = await _fetch(db_session, repo.repo_id)
292 assert rows[0].window_size == VelocityProvider._WINDOW
293
294 @pytest.mark.asyncio
295 async def test_VL_17_commits_analysed_column_populated(
296 self, db_session: AsyncSession, repo
297 ) -> None:
298 """commits_analysed column reflects the BFS walk length."""
299 commits = [_cid() for _ in range(5)]
300 prev = None
301 for cid in commits:
302 await _seed_commit(db_session, repo.repo_id, cid,
303 [prev] if prev else [])
304 prev = cid
305 await _seed_history(db_session, repo.repo_id, commits[0],
306 ["src/billing.py::fn"])
307 await db_session.commit()
308 await _run(db_session, repo.repo_id, commits[-1])
309 rows = await _fetch(db_session, repo.repo_id)
310 assert rows[0].commits_analysed == 5
311
312 @pytest.mark.asyncio
313 async def test_VL_18_result_key_correct(
314 self, db_session: AsyncSession, repo
315 ) -> None:
316 """Provider returns result tuple with key 'intel.code.velocity'."""
317 c1 = _cid()
318 await _seed_commit(db_session, repo.repo_id, c1)
319 await _seed_history(db_session, repo.repo_id, c1,
320 ["src/billing.py::fn"])
321 await db_session.commit()
322 result = await _run(db_session, repo.repo_id, c1)
323 assert len(result) == 1
324 key, payload = result[0]
325 assert key == "intel.code.velocity"
326 assert "count" in payload
327 assert "commits_analysed" in payload
328 assert "truncated" in payload
329
330
331 # ─────────────────────────────────────────────────────────────────────────────
332 # Tier 3 — E2E: full seeded scenarios, window semantics
333 # ─────────────────────────────────────────────────────────────────────────────
334
335 class TestVelocityE2E:
336
337 @pytest.mark.asyncio
338 async def test_VL_19_hottest_module_ranked_first(
339 self, db_session: AsyncSession, repo
340 ) -> None:
341 """Module with more active commits is ranked first by active_commits."""
342 commits = [_cid() for _ in range(5)]
343 prev = None
344 for cid in commits:
345 await _seed_commit(db_session, repo.repo_id, cid,
346 [prev] if prev else [])
347 prev = cid
348 # services/ in all 5; tests/ in only 2
349 for cid in commits:
350 await _seed_history(db_session, repo.repo_id, cid,
351 ["musehub/services/foo.py::fn"])
352 for cid in commits[:2]:
353 await _seed_history(db_session, repo.repo_id, cid,
354 ["tests/test_foo.py::test_fn"])
355 await db_session.commit()
356 await _run(db_session, repo.repo_id, commits[-1])
357 rows = await _fetch(db_session, repo.repo_id)
358 assert rows[0].module == "musehub/services/"
359
360 @pytest.mark.asyncio
361 async def test_VL_20_two_modules_produce_two_rows(
362 self, db_session: AsyncSession, repo
363 ) -> None:
364 """Symbols from two distinct modules produce two velocity rows."""
365 c1 = _cid()
366 await _seed_commit(db_session, repo.repo_id, c1)
367 await _seed_history(db_session, repo.repo_id, c1,
368 ["src/a.py::fn", "tests/test_a.py::test_fn"])
369 await db_session.commit()
370 await _run(db_session, repo.repo_id, c1)
371 rows = await _fetch(db_session, repo.repo_id)
372 modules = {r.module for r in rows}
373 assert "src/" in modules
374 assert "tests/" in modules
375
376 @pytest.mark.asyncio
377 async def test_VL_21_stagnant_commit_detected(
378 self, db_session: AsyncSession, repo
379 ) -> None:
380 """A commit where added==removed for a module increments stagnant_commits."""
381 c1 = _cid()
382 await _seed_commit(db_session, repo.repo_id, c1)
383 # One add + one delete in same module + same commit → net=0 → stagnant
384 await _seed_history(db_session, repo.repo_id, c1,
385 ["src/billing.py::new_fn"], op="add")
386 await _seed_history(db_session, repo.repo_id, c1,
387 ["src/billing.py::old_fn"], op="delete")
388 await db_session.commit()
389 await _run(db_session, repo.repo_id, c1)
390 rows = await _fetch(db_session, repo.repo_id)
391 assert rows[0].stagnant_commits == 1
392
393 @pytest.mark.asyncio
394 async def test_VL_22_non_stagnant_commit_not_counted(
395 self, db_session: AsyncSession, repo
396 ) -> None:
397 """A commit with net != 0 does not increment stagnant_commits."""
398 c1 = _cid()
399 await _seed_commit(db_session, repo.repo_id, c1)
400 await _seed_history(db_session, repo.repo_id, c1,
401 ["src/billing.py::fn"], op="add")
402 await db_session.commit()
403 await _run(db_session, repo.repo_id, c1)
404 rows = await _fetch(db_session, repo.repo_id)
405 assert rows[0].stagnant_commits == 0
406
407 @pytest.mark.asyncio
408 async def test_VL_23_prior_window_populates_prior_fields(
409 self, db_session: AsyncSession, repo
410 ) -> None:
411 """Commits beyond _WINDOW land in the prior window and set prior_* fields."""
412 provider = VelocityProvider()
413 n = provider._WINDOW + 3
414 commits = [_cid() for _ in range(n)]
415 prev = None
416 for cid in commits:
417 await _seed_commit(db_session, repo.repo_id, cid,
418 [prev] if prev else [])
419 prev = cid
420 # touch src/ in all commits → first _WINDOW go to current, rest to prior
421 for cid in commits:
422 await _seed_history(db_session, repo.repo_id, cid,
423 ["src/billing.py::fn"], op="add")
424 await db_session.commit()
425 await _run(db_session, repo.repo_id, commits[-1])
426 rows = await _fetch(db_session, repo.repo_id)
427 row = rows[0]
428 assert row.prior_active_commits > 0
429
430 @pytest.mark.asyncio
431 async def test_VL_24_positive_acceleration_when_current_more_active(
432 self, db_session: AsyncSession, repo
433 ) -> None:
434 """acceleration > 0 when current window has higher net than prior."""
435 provider = VelocityProvider()
436 # prior window: 1 add per commit; current window: 3 adds per commit
437 prior_commits = [_cid() for _ in range(provider._WINDOW)]
438 current_commits = [_cid() for _ in range(provider._WINDOW)]
439 all_commits = prior_commits + current_commits
440 prev = None
441 for cid in all_commits:
442 await _seed_commit(db_session, repo.repo_id, cid,
443 [prev] if prev else [])
444 prev = cid
445 for cid in prior_commits:
446 await _seed_history(db_session, repo.repo_id, cid,
447 ["src/billing.py::fn1"], op="add")
448 for cid in current_commits:
449 for sym in ["src/billing.py::fn1", "src/billing.py::fn2",
450 "src/billing.py::fn3"]:
451 await _seed_history(db_session, repo.repo_id, cid,
452 [sym], op="add")
453 await db_session.commit()
454 await _run(db_session, repo.repo_id, all_commits[-1])
455 rows = await _fetch(db_session, repo.repo_id)
456 src_row = next(r for r in rows if r.module == "src/")
457 assert src_row.acceleration > 0
458
459 @pytest.mark.asyncio
460 async def test_VL_25_module_only_in_current_has_zero_prior(
461 self, db_session: AsyncSession, repo
462 ) -> None:
463 """A module only touched in the current window has prior_active_commits=0."""
464 c1 = _cid()
465 await _seed_commit(db_session, repo.repo_id, c1)
466 await _seed_history(db_session, repo.repo_id, c1,
467 ["src/billing.py::fn"])
468 await db_session.commit()
469 await _run(db_session, repo.repo_id, c1)
470 rows = await _fetch(db_session, repo.repo_id)
471 assert rows[0].prior_active_commits == 0
472 assert rows[0].prior_net == 0
473
474
475 # ─────────────────────────────────────────────────────────────────────────────
476 # Tier 4 — Performance: timing bounds
477 # ─────────────────────────────────────────────────────────────────────────────
478
479 class TestVelocityPerformance:
480
481 @pytest.mark.asyncio
482 async def test_VL_26_ten_commits_five_modules_under_500ms(
483 self, db_session: AsyncSession, repo
484 ) -> None:
485 """10 commits × 5 modules completes in under 500 ms."""
486 commits = [_cid() for _ in range(10)]
487 prev = None
488 for cid in commits:
489 await _seed_commit(db_session, repo.repo_id, cid,
490 [prev] if prev else [])
491 prev = cid
492 for cid in commits:
493 for i in range(5):
494 await _seed_history(db_session, repo.repo_id, cid,
495 [f"mod{i}/file.py::fn"])
496 await db_session.commit()
497 t0 = time.monotonic()
498 await _run(db_session, repo.repo_id, commits[-1])
499 assert time.monotonic() - t0 < 0.5
500
501 @pytest.mark.asyncio
502 async def test_VL_27_forty_commits_ten_modules_under_2s(
503 self, db_session: AsyncSession, repo
504 ) -> None:
505 """40 commits × 10 modules completes in under 2 s."""
506 commits = [_cid() for _ in range(40)]
507 prev = None
508 for cid in commits:
509 await _seed_commit(db_session, repo.repo_id, cid,
510 [prev] if prev else [])
511 prev = cid
512 for cid in commits:
513 for i in range(10):
514 await _seed_history(db_session, repo.repo_id, cid,
515 [f"mod{i}/file.py::fn"])
516 await db_session.commit()
517 t0 = time.monotonic()
518 await _run(db_session, repo.repo_id, commits[-1])
519 assert time.monotonic() - t0 < 2.0
520
521 @pytest.mark.asyncio
522 async def test_VL_28_empty_repo_fast_path_under_50ms(
523 self, db_session: AsyncSession, repo
524 ) -> None:
525 """Empty repo fast-path exits under 50 ms."""
526 t0 = time.monotonic()
527 await _run(db_session, repo.repo_id, _cid())
528 assert time.monotonic() - t0 < 0.05
529
530 @pytest.mark.asyncio
531 async def test_VL_29_rerun_not_5x_slower(
532 self, db_session: AsyncSession, repo
533 ) -> None:
534 """Second run is not more than 5× slower than the first."""
535 c1 = _cid()
536 await _seed_commit(db_session, repo.repo_id, c1)
537 await _seed_history(db_session, repo.repo_id, c1, ["src/a.py::fn"])
538 await db_session.commit()
539 t1 = time.monotonic(); await _run(db_session, repo.repo_id, c1); d1 = time.monotonic() - t1
540 t2 = time.monotonic(); await _run(db_session, repo.repo_id, c1); d2 = time.monotonic() - t2
541 assert d2 < max(d1 * 5, 0.5)
542
543 @pytest.mark.asyncio
544 async def test_VL_30_point_lookup_under_10ms(
545 self, db_session: AsyncSession, repo
546 ) -> None:
547 """Fetching velocity rows for a repo is sub-10 ms after provider run."""
548 c1 = _cid()
549 await _seed_commit(db_session, repo.repo_id, c1)
550 await _seed_history(db_session, repo.repo_id, c1, ["src/a.py::fn"])
551 await db_session.commit()
552 await _run(db_session, repo.repo_id, c1)
553 t0 = time.monotonic()
554 await _fetch(db_session, repo.repo_id)
555 assert time.monotonic() - t0 < 0.01
556
557 @pytest.mark.asyncio
558 async def test_VL_31_top20_leaderboard_query_fast(
559 self, db_session: AsyncSession, repo
560 ) -> None:
561 """Fetching top-20 leaderboard from the table is sub-50 ms."""
562 commits = [_cid() for _ in range(5)]
563 prev = None
564 for cid in commits:
565 await _seed_commit(db_session, repo.repo_id, cid,
566 [prev] if prev else [])
567 prev = cid
568 for cid in commits:
569 for i in range(20):
570 await _seed_history(db_session, repo.repo_id, cid,
571 [f"mod{i}/file.py::fn"])
572 await db_session.commit()
573 await _run(db_session, repo.repo_id, commits[-1])
574 t0 = time.monotonic()
575 await db_session.execute(
576 sa.select(db.MusehubIntelVelocity)
577 .where(db.MusehubIntelVelocity.repo_id == repo.repo_id)
578 .order_by(sa.desc(db.MusehubIntelVelocity.active_commits))
579 .limit(20)
580 )
581 assert time.monotonic() - t0 < 0.05
582
583 @pytest.mark.asyncio
584 async def test_VL_32_dashboard_preview_query_fast(
585 self, db_session: AsyncSession, repo
586 ) -> None:
587 """Dashboard preview (top 5, LIMIT query) completes under 20 ms."""
588 c1 = _cid()
589 await _seed_commit(db_session, repo.repo_id, c1)
590 for i in range(5):
591 await _seed_history(db_session, repo.repo_id, c1,
592 [f"mod{i}/file.py::fn"])
593 await db_session.commit()
594 await _run(db_session, repo.repo_id, c1)
595 t0 = time.monotonic()
596 await db_session.execute(
597 sa.select(db.MusehubIntelVelocity)
598 .where(db.MusehubIntelVelocity.repo_id == repo.repo_id)
599 .order_by(sa.desc(db.MusehubIntelVelocity.active_commits))
600 .limit(5)
601 )
602 assert time.monotonic() - t0 < 0.02
603
604
605 # ─────────────────────────────────────────────────────────────────────────────
606 # Tier 5 — State: idempotency, stale-row purge, incremental updates
607 # ─────────────────────────────────────────────────────────────────────────────
608
609 class TestVelocityState:
610
611 @pytest.mark.asyncio
612 async def test_VL_33_idempotent_two_runs(
613 self, db_session: AsyncSession, repo
614 ) -> None:
615 """Running the provider twice produces identical rows."""
616 c1 = _cid()
617 await _seed_commit(db_session, repo.repo_id, c1)
618 await _seed_history(db_session, repo.repo_id, c1, ["src/a.py::fn"])
619 await db_session.commit()
620 await _run(db_session, repo.repo_id, c1)
621 first = {(r.module, r.active_commits, r.net)
622 for r in await _fetch(db_session, repo.repo_id)}
623 await _run(db_session, repo.repo_id, c1)
624 second = {(r.module, r.active_commits, r.net)
625 for r in await _fetch(db_session, repo.repo_id)}
626 assert first == second
627
628 @pytest.mark.asyncio
629 async def test_VL_34_stale_rows_purged_on_rerun(
630 self, db_session: AsyncSession, repo
631 ) -> None:
632 """Re-run deletes all old rows before inserting fresh set."""
633 c1 = _cid()
634 await _seed_commit(db_session, repo.repo_id, c1)
635 await _seed_history(db_session, repo.repo_id, c1, ["src/a.py::fn"])
636 await db_session.commit()
637 await _run(db_session, repo.repo_id, c1)
638 count_first = (await db_session.execute(
639 sa.select(sa.func.count()).select_from(db.MusehubIntelVelocity)
640 .where(db.MusehubIntelVelocity.repo_id == repo.repo_id)
641 )).scalar_one()
642 await _run(db_session, repo.repo_id, c1)
643 count_second = (await db_session.execute(
644 sa.select(sa.func.count()).select_from(db.MusehubIntelVelocity)
645 .where(db.MusehubIntelVelocity.repo_id == repo.repo_id)
646 )).scalar_one()
647 assert count_first == count_second
648
649 @pytest.mark.asyncio
650 async def test_VL_35_incremental_new_module_appears(
651 self, db_session: AsyncSession, repo
652 ) -> None:
653 """After adding commits to a new module, it materialises on re-run."""
654 c1 = _cid()
655 await _seed_commit(db_session, repo.repo_id, c1)
656 await _seed_history(db_session, repo.repo_id, c1, ["src/a.py::fn"])
657 await db_session.commit()
658 await _run(db_session, repo.repo_id, c1)
659 modules_before = {r.module for r in await _fetch(db_session, repo.repo_id)}
660
661 c2 = _cid()
662 await _seed_commit(db_session, repo.repo_id, c2, [c1])
663 await _seed_history(db_session, repo.repo_id, c2, ["tests/test_a.py::test_fn"])
664 await db_session.commit()
665 await _run(db_session, repo.repo_id, c2)
666 modules_after = {r.module for r in await _fetch(db_session, repo.repo_id)}
667 assert len(modules_after) > len(modules_before)
668
669 @pytest.mark.asyncio
670 async def test_VL_36_no_duplicate_modules_after_three_runs(
671 self, db_session: AsyncSession, repo
672 ) -> None:
673 """No duplicate module rows after 3 consecutive runs."""
674 c1 = _cid()
675 await _seed_commit(db_session, repo.repo_id, c1)
676 await _seed_history(db_session, repo.repo_id, c1, ["src/a.py::fn"])
677 await db_session.commit()
678 for _ in range(3):
679 await _run(db_session, repo.repo_id, c1)
680 rows = await _fetch(db_session, repo.repo_id)
681 modules = [r.module for r in rows]
682 assert len(modules) == len(set(modules))
683
684 @pytest.mark.asyncio
685 async def test_VL_37_active_commits_increases_with_new_commits(
686 self, db_session: AsyncSession, repo
687 ) -> None:
688 """active_commits increases when more commits touch the module."""
689 c1, c2 = _cid(), _cid()
690 await _seed_commit(db_session, repo.repo_id, c1)
691 await _seed_history(db_session, repo.repo_id, c1, ["src/a.py::fn"])
692 await db_session.commit()
693 await _run(db_session, repo.repo_id, c1)
694 before = (await _fetch(db_session, repo.repo_id))[0].active_commits
695
696 await _seed_commit(db_session, repo.repo_id, c2, [c1])
697 await _seed_history(db_session, repo.repo_id, c2, ["src/a.py::fn"])
698 await db_session.commit()
699 await _run(db_session, repo.repo_id, c2)
700 after = (await _fetch(db_session, repo.repo_id))[0].active_commits
701 assert after > before
702
703 @pytest.mark.asyncio
704 async def test_VL_38_truncated_false_when_under_cap(
705 self, db_session: AsyncSession, repo
706 ) -> None:
707 """truncated=False when module count is within _TOP."""
708 c1 = _cid()
709 await _seed_commit(db_session, repo.repo_id, c1)
710 await _seed_history(db_session, repo.repo_id, c1, ["src/a.py::fn"])
711 await db_session.commit()
712 result = await _run(db_session, repo.repo_id, c1)
713 key, payload = result[0]
714 assert payload["truncated"] is False
715
716
717 # ─────────────────────────────────────────────────────────────────────────────
718 # Tier 6 — Security: injection, isolation, unicode
719 # ─────────────────────────────────────────────────────────────────────────────
720
721 class TestVelocitySecurity:
722
723 @pytest.mark.asyncio
724 async def test_VL_39_sql_injection_stored_verbatim(
725 self, db_session: AsyncSession, repo
726 ) -> None:
727 """SQL injection in symbol address stored as-is; table survives."""
728 inject = "src/a.py::fn'; DROP TABLE musehub_intel_velocity; --"
729 c1 = _cid()
730 await _seed_commit(db_session, repo.repo_id, c1)
731 await _seed_history(db_session, repo.repo_id, c1, [inject])
732 await db_session.commit()
733 await _run(db_session, repo.repo_id, c1)
734 assert isinstance(await _fetch(db_session, repo.repo_id), list)
735
736 @pytest.mark.asyncio
737 async def test_VL_40_xss_payload_stored_safely(
738 self, db_session: AsyncSession, repo
739 ) -> None:
740 """XSS payload in symbol address stored without execution."""
741 xss = "src/<script>alert(1)</script>.py::fn"
742 c1 = _cid()
743 await _seed_commit(db_session, repo.repo_id, c1)
744 await _seed_history(db_session, repo.repo_id, c1, [xss])
745 await db_session.commit()
746 await _run(db_session, repo.repo_id, c1)
747 assert isinstance(await _fetch(db_session, repo.repo_id), list)
748
749 @pytest.mark.asyncio
750 async def test_VL_41_repo_isolation_strict(
751 self, db_session: AsyncSession, two_repos
752 ) -> None:
753 """Velocity rows from repo A are never visible when querying repo B."""
754 r1, r2 = two_repos
755 c1 = _cid()
756 await _seed_commit(db_session, r1.repo_id, c1)
757 await _seed_history(db_session, r1.repo_id, c1, ["src/a.py::fn"])
758 await db_session.commit()
759 await _run(db_session, r1.repo_id, c1)
760 assert await _fetch(db_session, r2.repo_id) == []
761
762 @pytest.mark.asyncio
763 async def test_VL_42_two_repos_independent_rows(
764 self, db_session: AsyncSession, two_repos
765 ) -> None:
766 """Two repos each produce their own independent velocity rows."""
767 r1, r2 = two_repos
768 for repo in [r1, r2]:
769 c1 = _cid()
770 await _seed_commit(db_session, repo.repo_id, c1)
771 await _seed_history(db_session, repo.repo_id, c1,
772 ["src/a.py::fn"])
773 await db_session.commit()
774 await _run(db_session, repo.repo_id, c1)
775 rows1 = await _fetch(db_session, r1.repo_id)
776 rows2 = await _fetch(db_session, r2.repo_id)
777 assert all(r.repo_id == r1.repo_id for r in rows1)
778 assert all(r.repo_id == r2.repo_id for r in rows2)
779
780 @pytest.mark.asyncio
781 async def test_VL_43_rerun_updates_ref_column(
782 self, db_session: AsyncSession, repo
783 ) -> None:
784 """Re-run for a new ref updates the ref column on all rows."""
785 c1, c2 = _cid(), _cid()
786 await _seed_commit(db_session, repo.repo_id, c1)
787 await _seed_commit(db_session, repo.repo_id, c2, [c1])
788 for cid in [c1, c2]:
789 await _seed_history(db_session, repo.repo_id, cid,
790 ["src/a.py::fn"])
791 await db_session.commit()
792 await _run(db_session, repo.repo_id, c1)
793 await _run(db_session, repo.repo_id, c2)
794 rows = await _fetch(db_session, repo.repo_id)
795 assert all(r.ref == c2 for r in rows)
796
797 @pytest.mark.asyncio
798 async def test_VL_44_unicode_in_path_handled(
799 self, db_session: AsyncSession, repo
800 ) -> None:
801 """Unicode characters in symbol paths do not crash the provider."""
802 c1 = _cid()
803 await _seed_commit(db_session, repo.repo_id, c1)
804 await _seed_history(db_session, repo.repo_id, c1,
805 ["src/música.py::canción"])
806 await db_session.commit()
807 await _run(db_session, repo.repo_id, c1)
808 assert isinstance(await _fetch(db_session, repo.repo_id), list)
809
810
811 # ─────────────────────────────────────────────────────────────────────────────
812 # Tier 7 — Stress: TOP cap, BFS cap, extended-column completeness
813 # ─────────────────────────────────────────────────────────────────────────────
814
815 class TestVelocityStress:
816
817 @pytest.mark.asyncio
818 async def test_VL_45_top_cap_respected(
819 self, db_session: AsyncSession, repo
820 ) -> None:
821 """Stored module count never exceeds _TOP."""
822 provider = VelocityProvider()
823 c1 = _cid()
824 await _seed_commit(db_session, repo.repo_id, c1)
825 # _TOP + 5 distinct modules
826 for i in range(provider._TOP + 5):
827 await _seed_history(db_session, repo.repo_id, c1,
828 [f"mod{i:03d}/file.py::fn"])
829 await db_session.commit()
830 await _run(db_session, repo.repo_id, c1)
831 rows = await _fetch(db_session, repo.repo_id)
832 assert len(rows) <= provider._TOP
833
834 @pytest.mark.asyncio
835 async def test_VL_46_truncated_true_over_top_cap(
836 self, db_session: AsyncSession, repo
837 ) -> None:
838 """truncated=True when distinct module count exceeds _TOP."""
839 provider = VelocityProvider()
840 c1 = _cid()
841 await _seed_commit(db_session, repo.repo_id, c1)
842 for i in range(provider._TOP + 1):
843 await _seed_history(db_session, repo.repo_id, c1,
844 [f"mod{i:03d}/file.py::fn"])
845 await db_session.commit()
846 result = await _run(db_session, repo.repo_id, c1)
847 key, payload = result[0]
848 assert payload["truncated"] is True
849
850 @pytest.mark.asyncio
851 async def test_VL_47_500_commits_completes_without_error(
852 self, db_session: AsyncSession, repo
853 ) -> None:
854 """500 commits × 3 modules completes without error."""
855 commits = [_cid() for _ in range(500)]
856 prev = None
857 for cid in commits:
858 await _seed_commit(db_session, repo.repo_id, cid,
859 [prev] if prev else [])
860 prev = cid
861 for cid in commits:
862 for i in range(3):
863 await _seed_history(db_session, repo.repo_id, cid,
864 [f"mod{i}/file.py::fn"])
865 await db_session.commit()
866 result = await _run(db_session, repo.repo_id, commits[-1])
867 assert result
868
869 @pytest.mark.asyncio
870 async def test_VL_48_result_count_matches_stored_rows(
871 self, db_session: AsyncSession, repo
872 ) -> None:
873 """metadata 'count' always equals len(stored rows)."""
874 commits = [_cid() for _ in range(4)]
875 prev = None
876 for cid in commits:
877 await _seed_commit(db_session, repo.repo_id, cid,
878 [prev] if prev else [])
879 prev = cid
880 for cid in commits:
881 for i in range(3):
882 await _seed_history(db_session, repo.repo_id, cid,
883 [f"mod{i}/file.py::fn"])
884 await db_session.commit()
885 result = await _run(db_session, repo.repo_id, commits[-1])
886 key, payload = result[0]
887 rows = await _fetch(db_session, repo.repo_id)
888 assert payload["count"] == len(rows)
889
890 @pytest.mark.asyncio
891 async def test_VL_49_bfs_walk_cap_never_exceeded(
892 self, db_session: AsyncSession, repo
893 ) -> None:
894 """commits_analysed never exceeds _MAX_WALK."""
895 provider = VelocityProvider()
896 commits = [_cid() for _ in range(50)]
897 prev = None
898 for cid in commits:
899 await _seed_commit(db_session, repo.repo_id, cid,
900 [prev] if prev else [])
901 prev = cid
902 await _seed_history(db_session, repo.repo_id, commits[0],
903 ["src/a.py::fn"])
904 await db_session.commit()
905 result = await _run(db_session, repo.repo_id, commits[-1])
906 if result:
907 key, payload = result[0]
908 assert payload["commits_analysed"] <= provider._MAX_WALK
909
910 @pytest.mark.asyncio
911 async def test_VL_50_all_extended_columns_non_null(
912 self, db_session: AsyncSession, repo
913 ) -> None:
914 """Every stored row has non-null values for all four extended columns."""
915 provider = VelocityProvider()
916 n = provider._WINDOW + 3
917 commits = [_cid() for _ in range(n)]
918 prev = None
919 for cid in commits:
920 await _seed_commit(db_session, repo.repo_id, cid,
921 [prev] if prev else [])
922 prev = cid
923 for cid in commits:
924 await _seed_history(db_session, repo.repo_id, cid,
925 ["src/a.py::fn"])
926 await db_session.commit()
927 await _run(db_session, repo.repo_id, commits[-1])
928 rows = await _fetch(db_session, repo.repo_id)
929 assert rows, "expected at least one velocity row"
930 for r in rows:
931 assert r.prior_modified is not None
932 assert r.prior_active_commits is not None
933 assert r.window_size is not None
934 assert r.commits_analysed is not None
File History 1 commit
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor 142 days ago