gabriel / musehub public
test_divergence.py python
922 lines 33.3 KB
Raw
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ breaking 142 days ago
1 """Section 26 — Divergence Engine: 7-layer test suite.
2
3 Covers musehub/services/musehub_divergence.py and the
4 GET /api/repos/{repo_id}/divergence endpoint.
5
6 Layer map
7 ---------
8 1. Unit — classify_message, score_to_level, compute_hub_dimension_divergence,
9 find_common_ancestor, get_commits_since, extract_affected_sections,
10 _delta_label, build_zero_diff_response, constants
11 2. Integration — get_branch_commits, compute_hub_divergence (full pipeline)
12 3. E2E — HTTP GET /api/repos/{repo_id}/divergence
13 4. Stress — large histories, many-dimension calls, concurrent computes
14 5. Data Integrity — score bounds, ordering, common ancestor correctness
15 6. Security — auth / visibility, invalid branch names safe
16 7. Performance — timing budgets
17 """
18 from __future__ import annotations
19
20 import asyncio
21 import time
22 import uuid
23 from datetime import datetime, timezone, timedelta
24
25 import pytest
26 from httpx import AsyncClient
27 from sqlalchemy.ext.asyncio import AsyncSession
28 from unittest.mock import MagicMock
29
30 from musehub.core.genesis import compute_identity_id, compute_repo_id
31 from musehub.db.musehub_models import MusehubCommit, MusehubRepo
32 from musehub.types.json_types import StrDict
33 from musehub.services.musehub_divergence import (
34 ALL_DIMENSIONS,
35 _DIMENSION_PATTERNS,
36 _SECTION_RE,
37 MuseHubDivergenceLevel,
38 MuseHubDivergenceResult,
39 MuseHubDimensionDivergence,
40 _delta_label,
41 build_proposal_diff_response,
42 build_zero_diff_response,
43 classify_message,
44 compute_hub_dimension_divergence,
45 compute_hub_divergence,
46 extract_affected_sections,
47 find_common_ancestor,
48 get_branch_commits,
49 get_commits_since,
50 score_to_level,
51 )
52
53
54 # ---------------------------------------------------------------------------
55 # DB helpers
56 # ---------------------------------------------------------------------------
57
58
59 def _uid() -> str:
60 return str(uuid.uuid4())
61
62
63 def _cid() -> str:
64 """Short commit-id style string."""
65 return uuid.uuid4().hex
66
67
68 _TEST_OWNER_ID = compute_identity_id(b"test-divergence-owner")
69
70
71 async def _db_repo(session: AsyncSession, *, visibility: str = "private") -> str:
72 from datetime import datetime, timezone
73 slug = f"repo-{uuid.uuid4().hex[:8]}"
74 created_at = datetime.now(timezone.utc)
75 repo_id = compute_repo_id(_TEST_OWNER_ID, slug, "code", created_at.isoformat())
76 repo = MusehubRepo(
77 repo_id=repo_id,
78 name=slug,
79 slug=slug,
80 owner="testuser",
81 owner_user_id=_TEST_OWNER_ID,
82 visibility=visibility,
83 created_at=created_at,
84 updated_at=created_at,
85 )
86 session.add(repo)
87 await session.flush()
88 return repo.repo_id
89
90
91 async def _db_commit(
92 session: AsyncSession,
93 repo_id: str,
94 *,
95 branch: str = "main",
96 message: str = "add groove",
97 parent_ids: list[str] | None = None,
98 ts: datetime | None = None,
99 ) -> MusehubCommit:
100 c = MusehubCommit(
101 commit_id=_cid(),
102 repo_id=repo_id,
103 branch=branch,
104 parent_ids=parent_ids or [],
105 message=message,
106 author="agent",
107 timestamp=ts or datetime.now(timezone.utc),
108 )
109 session.add(c)
110 await session.flush()
111 return c
112
113
114 async def _api_repo(
115 client: AsyncClient,
116 auth_headers: StrDict,
117 *,
118 visibility: str = "private",
119 ) -> str:
120 r = await client.post(
121 "/api/repos",
122 json={
123 "name": f"div-{_uid()[:8]}",
124 "owner": "testuser",
125 "visibility": visibility,
126 },
127 headers=auth_headers,
128 )
129 assert r.status_code == 201, r.text
130 return r.json()["repoId"]
131
132
133 # ===========================================================================
134 # Layer 1 — Unit
135 # ===========================================================================
136
137
138 class TestUnitConstants:
139 def test_all_dimensions_five(self) -> None:
140 assert len(ALL_DIMENSIONS) == 5
141
142 def test_all_dimensions_names(self) -> None:
143 assert set(ALL_DIMENSIONS) == {"melodic", "harmonic", "rhythmic", "structural", "dynamic"}
144
145 def test_dimension_patterns_covers_all(self) -> None:
146 for dim in ALL_DIMENSIONS:
147 assert dim in _DIMENSION_PATTERNS
148
149 def test_section_re_compiles(self) -> None:
150 assert _SECTION_RE.pattern is not None
151
152
153 class TestUnitClassifyMessage:
154 def test_melodic_keywords(self) -> None:
155 assert "melodic" in classify_message("add melody line")
156 assert "melodic" in classify_message("record lead solo")
157 assert "melodic" in classify_message("fix pitch drift")
158
159 def test_harmonic_keywords(self) -> None:
160 assert "harmonic" in classify_message("add chord progression")
161 assert "harmonic" in classify_message("change key to Dm")
162
163 def test_rhythmic_keywords(self) -> None:
164 assert "rhythmic" in classify_message("adjust tempo to 120 bpm")
165 assert "rhythmic" in classify_message("tighten drum groove")
166
167 def test_structural_keywords(self) -> None:
168 assert "structural" in classify_message("rewrite bridge section")
169 assert "structural" in classify_message("add chorus after verse")
170
171 def test_dynamic_keywords(self) -> None:
172 assert "dynamic" in classify_message("apply reverb to guitar")
173 assert "dynamic" in classify_message("master mix levels")
174
175 def test_multi_dimension_message(self) -> None:
176 dims = classify_message("add jazzy chord melody with reverb")
177 assert "melodic" in dims
178 assert "harmonic" in dims
179 assert "dynamic" in dims
180
181 def test_unclassified_returns_empty(self) -> None:
182 assert classify_message("update README") == set()
183 assert classify_message("fix typo in config") == set()
184
185 def test_case_insensitive(self) -> None:
186 assert "melodic" in classify_message("Add MELODY line")
187 assert "rhythmic" in classify_message("DRUM pattern fix")
188
189 def test_empty_message(self) -> None:
190 assert classify_message("") == set()
191
192
193 class TestUnitScoreToLevel:
194 def test_zero_is_none(self) -> None:
195 assert score_to_level(0.0) == MuseHubDivergenceLevel.NONE
196
197 def test_boundary_0_15_is_low(self) -> None:
198 assert score_to_level(0.15) == MuseHubDivergenceLevel.LOW
199
200 def test_mid_range_low(self) -> None:
201 assert score_to_level(0.25) == MuseHubDivergenceLevel.LOW
202
203 def test_boundary_0_40_is_med(self) -> None:
204 assert score_to_level(0.40) == MuseHubDivergenceLevel.MED
205
206 def test_mid_range_med(self) -> None:
207 assert score_to_level(0.55) == MuseHubDivergenceLevel.MED
208
209 def test_boundary_0_70_is_high(self) -> None:
210 assert score_to_level(0.70) == MuseHubDivergenceLevel.HIGH
211
212 def test_one_is_high(self) -> None:
213 assert score_to_level(1.0) == MuseHubDivergenceLevel.HIGH
214
215 def test_just_below_0_15_is_none(self) -> None:
216 assert score_to_level(0.14) == MuseHubDivergenceLevel.NONE
217
218
219 class TestUnitComputeHubDimensionDivergence:
220 def _make_commit(self, cid: str) -> MusehubCommit:
221 c = MusehubCommit.__new__(MusehubCommit)
222 object.__setattr__(c, "commit_id", cid)
223 return c
224
225 def test_identical_sets_score_zero(self) -> None:
226 a_ids = {"c1", "c2"}
227 b_ids = {"c1", "c2"}
228 a_msgs = {"c1": "add chord", "c2": "fix chord progression"}
229 b_msgs = {"c1": "add chord", "c2": "fix chord progression"}
230 result = compute_hub_dimension_divergence("harmonic", a_ids, b_ids, a_msgs, b_msgs)
231 assert result.score == 0.0
232 assert result.level == MuseHubDivergenceLevel.NONE
233
234 def test_disjoint_sets_score_one(self) -> None:
235 a_ids = {"c1"}
236 b_ids = {"c2"}
237 a_msgs = {"c1": "add chord"}
238 b_msgs = {"c2": "fix harmony"}
239 result = compute_hub_dimension_divergence("harmonic", a_ids, b_ids, a_msgs, b_msgs)
240 assert result.score == 1.0
241 assert result.level == MuseHubDivergenceLevel.HIGH
242
243 def test_no_matching_dimension_score_zero(self) -> None:
244 a_ids = {"c1"}
245 b_ids = {"c2"}
246 a_msgs = {"c1": "fix typo"} # no harmonic keywords
247 b_msgs = {"c2": "update readme"}
248 result = compute_hub_dimension_divergence("harmonic", a_ids, b_ids, a_msgs, b_msgs)
249 assert result.score == 0.0
250 assert "No harmonic" in result.description
251
252 def test_branch_commit_counts(self) -> None:
253 a_ids = {"c1", "c2"}
254 b_ids = {"c3"}
255 a_msgs = {"c1": "add melody", "c2": "fix melody riff"}
256 b_msgs = {"c3": "add melody"}
257 result = compute_hub_dimension_divergence("melodic", a_ids, b_ids, a_msgs, b_msgs)
258 assert result.branch_a_commits == 2
259 assert result.branch_b_commits == 1
260
261 def test_score_rounded_to_4dp(self) -> None:
262 # 1 overlap, 3 symmetric diff → score = 2/3 ≈ 0.6667
263 a_ids = {"c1", "c2"}
264 b_ids = {"c1", "c3"}
265 msgs = {"c1": "add chord", "c2": "fix harmony key", "c3": "harmonic voicing"}
266 result = compute_hub_dimension_divergence("harmonic", a_ids, b_ids, msgs, msgs)
267 assert len(str(result.score).split(".")[-1]) <= 4
268
269 def test_partial_overlap_score_between_0_and_1(self) -> None:
270 a_ids = {"c1", "c2", "c3"}
271 b_ids = {"c1", "c4"}
272 msgs = {
273 "c1": "add melody",
274 "c2": "melody riff",
275 "c3": "lead melody",
276 "c4": "solo melody",
277 }
278 result = compute_hub_dimension_divergence("melodic", a_ids, b_ids, msgs, msgs)
279 assert 0.0 < result.score < 1.0
280
281
282 def _stub_commit(cid: str, parent_ids: list[str] | None = None) -> MusehubCommit:
283 """Create a lightweight commit stub for unit tests (no DB session needed)."""
284 c = MagicMock(spec=MusehubCommit)
285 c.commit_id = cid
286 c.parent_ids = parent_ids or []
287 c.timestamp = datetime.now(timezone.utc)
288 return c
289
290
291 class TestUnitFindCommonAncestor:
292 def test_shared_commit_is_ancestor(self) -> None:
293 base = _stub_commit("base")
294 a1 = _stub_commit("a1", parent_ids=["base"])
295 b1 = _stub_commit("b1", parent_ids=["base"])
296 result = find_common_ancestor([a1, base], [b1, base])
297 assert result == "base"
298
299 def test_disjoint_histories_returns_none(self) -> None:
300 a = _stub_commit("a1")
301 b = _stub_commit("b1")
302 result = find_common_ancestor([a], [b])
303 assert result is None
304
305 def test_same_branch_head_is_ancestor(self) -> None:
306 c = _stub_commit("shared")
307 result = find_common_ancestor([c], [c])
308 assert result == "shared"
309
310 def test_empty_branches_returns_none(self) -> None:
311 result = find_common_ancestor([], [])
312 assert result is None
313
314
315 class TestUnitGetCommitsSince:
316 def test_none_base_returns_all(self) -> None:
317 commits = [_stub_commit(f"c{i}") for i in range(5)]
318 result = get_commits_since(commits, None)
319 assert len(result) == 5
320
321 def test_excludes_base_commit(self) -> None:
322 commits = [_stub_commit(f"c{i}") for i in range(3)]
323 result = get_commits_since(commits, "c1")
324 ids = [c.commit_id for c in result]
325 assert "c1" not in ids
326 assert "c0" in ids
327 assert "c2" in ids
328
329 def test_empty_list_returns_empty(self) -> None:
330 assert get_commits_since([], "c1") == []
331
332
333 class TestUnitExtractAffectedSections:
334 def test_finds_bridge(self) -> None:
335 assert "Bridge" in extract_affected_sections(("rewrite the bridge",))
336
337 def test_finds_chorus_and_verse(self) -> None:
338 sections = extract_affected_sections(("fix chorus timing", "extend the verse"))
339 assert "Chorus" in sections
340 assert "Verse" in sections
341
342 def test_case_insensitive(self) -> None:
343 assert "Intro" in extract_affected_sections(("add INTRO section",))
344
345 def test_no_section_keywords_returns_empty(self) -> None:
346 assert extract_affected_sections(("fix melody", "update readme")) == []
347
348 def test_deduplication(self) -> None:
349 sections = extract_affected_sections(("bridge fix", "bridge rewrite", "chorus"))
350 assert sections.count("Bridge") == 1
351
352 def test_empty_messages(self) -> None:
353 assert extract_affected_sections(()) == []
354
355
356 class TestUnitDeltaLabel:
357 def test_zero_is_unchanged(self) -> None:
358 assert _delta_label(0.0) == "unchanged"
359
360 def test_nonzero_has_plus_prefix(self) -> None:
361 label = _delta_label(0.5)
362 assert label.startswith("+")
363 assert "50.0" in label
364
365 def test_small_fraction(self) -> None:
366 label = _delta_label(0.001)
367 assert label.startswith("+")
368
369
370 class TestUnitBuildZeroDiffResponse:
371 def test_with_dimensions(self) -> None:
372 resp = build_zero_diff_response("proposal-1", "repo1", "feat", "main")
373 assert len(resp.dimensions) == 5
374 assert resp.overall_score == 0.0
375 assert all(d.score == 0.0 for d in resp.dimensions)
376
377 def test_without_dimensions_code_domain(self) -> None:
378 resp = build_zero_diff_response(
379 "proposal-1", "repo1", "feat", "main", include_dimensions=False
380 )
381 assert resp.dimensions == []
382 assert resp.overall_score is None
383
384 def test_affected_sections_empty(self) -> None:
385 resp = build_zero_diff_response("proposal-1", "repo1", "a", "b")
386 assert resp.affected_sections == []
387
388
389 # ===========================================================================
390 # Layer 2 — Integration
391 # ===========================================================================
392
393
394 class TestIntegrationGetBranchCommits:
395 async def test_returns_commits_for_branch(self, db_session: AsyncSession) -> None:
396 repo_id = await _db_repo(db_session)
397 await _db_commit(db_session, repo_id, branch="main", message="first")
398 await _db_commit(db_session, repo_id, branch="main", message="second")
399 await _db_commit(db_session, repo_id, branch="feat", message="feature")
400 await db_session.flush()
401
402 commits = await get_branch_commits(db_session, repo_id, "main")
403 assert len(commits) == 2
404 assert all(c.branch == "main" for c in commits)
405
406 async def test_newest_first_ordering(self, db_session: AsyncSession) -> None:
407 repo_id = await _db_repo(db_session)
408 ts = datetime(2026, 1, 1, tzinfo=timezone.utc)
409 await _db_commit(db_session, repo_id, ts=ts, message="old")
410 await _db_commit(db_session, repo_id, ts=ts + timedelta(hours=1), message="new")
411 await db_session.flush()
412
413 commits = await get_branch_commits(db_session, repo_id, "main")
414 assert commits[0].message == "new"
415 assert commits[1].message == "old"
416
417 async def test_empty_branch_returns_empty(self, db_session: AsyncSession) -> None:
418 repo_id = await _db_repo(db_session)
419 await db_session.flush()
420
421 commits = await get_branch_commits(db_session, repo_id, "nonexistent")
422 assert commits == []
423
424
425 class TestIntegrationComputeHubDivergence:
426 async def test_basic_divergence_two_branches(
427 self, db_session: AsyncSession
428 ) -> None:
429 repo_id = await _db_repo(db_session)
430 ts = datetime(2026, 1, 1, tzinfo=timezone.utc)
431 base = await _db_commit(
432 db_session, repo_id, branch="main", message="initial", ts=ts
433 )
434 await _db_commit(
435 db_session, repo_id, branch="main",
436 message="add chord progression", ts=ts + timedelta(hours=1),
437 parent_ids=[base.commit_id],
438 )
439 await _db_commit(
440 db_session, repo_id, branch="feat",
441 message="add melody riff", ts=ts + timedelta(hours=1),
442 parent_ids=[base.commit_id],
443 )
444 await db_session.flush()
445
446 result = await compute_hub_divergence(
447 db_session, repo_id=repo_id, branch_a="main", branch_b="feat"
448 )
449 assert result.repo_id == repo_id
450 assert result.branch_a == "main"
451 assert result.branch_b == "feat"
452 assert len(result.dimensions) == 5
453 assert 0.0 <= result.overall_score <= 1.0
454
455 async def test_raises_on_empty_branch(self, db_session: AsyncSession) -> None:
456 repo_id = await _db_repo(db_session)
457 await _db_commit(db_session, repo_id, branch="main")
458 await db_session.flush()
459
460 with pytest.raises(ValueError, match="no commits"):
461 await compute_hub_divergence(
462 db_session, repo_id=repo_id, branch_a="main", branch_b="nonexistent"
463 )
464
465 async def test_disjoint_branches_common_ancestor_none(
466 self, db_session: AsyncSession
467 ) -> None:
468 """With get_branch_commits filtering by branch label, two normally diverged
469 branches will always have common_ancestor=None — the DB model stores each
470 commit against a single branch, so ancestor intersection is empty."""
471 repo_id = await _db_repo(db_session)
472 ts = datetime(2026, 1, 1, tzinfo=timezone.utc)
473 await _db_commit(
474 db_session, repo_id, branch="main", message="main work", ts=ts
475 )
476 await _db_commit(
477 db_session, repo_id, branch="feat", message="feat work",
478 ts=ts + timedelta(hours=1),
479 )
480 await db_session.flush()
481
482 result = await compute_hub_divergence(
483 db_session, repo_id=repo_id, branch_a="main", branch_b="feat"
484 )
485 # commit_id is PK → same commit can't be on two branches → no intersection
486 assert result.common_ancestor is None
487
488 async def test_no_common_ancestor_fresh_fork(
489 self, db_session: AsyncSession
490 ) -> None:
491 """Two branches with completely disjoint histories → common_ancestor is None."""
492 repo_id = await _db_repo(db_session)
493 await _db_commit(db_session, repo_id, branch="main", message="main only")
494 await _db_commit(db_session, repo_id, branch="fork", message="fork only")
495 await db_session.flush()
496
497 result = await compute_hub_divergence(
498 db_session, repo_id=repo_id, branch_a="main", branch_b="fork"
499 )
500 assert result.common_ancestor is None
501
502 async def test_overall_score_is_mean_of_dimensions(
503 self, db_session: AsyncSession
504 ) -> None:
505 repo_id = await _db_repo(db_session)
506 await _db_commit(db_session, repo_id, branch="main", message="chord melody")
507 await _db_commit(db_session, repo_id, branch="feat", message="drum beat")
508 await db_session.flush()
509
510 result = await compute_hub_divergence(
511 db_session, repo_id=repo_id, branch_a="main", branch_b="feat"
512 )
513 expected = round(sum(d.score for d in result.dimensions) / 5, 4)
514 assert abs(result.overall_score - expected) < 1e-6
515
516 async def test_all_messages_captured(self, db_session: AsyncSession) -> None:
517 repo_id = await _db_repo(db_session)
518 await _db_commit(db_session, repo_id, branch="main", message="main msg")
519 await _db_commit(db_session, repo_id, branch="feat", message="feat msg")
520 await db_session.flush()
521
522 result = await compute_hub_divergence(
523 db_session, repo_id=repo_id, branch_a="main", branch_b="feat"
524 )
525 assert "main msg" in result.all_messages
526 assert "feat msg" in result.all_messages
527
528
529 class TestIntegrationBuildProposalDiffResponse:
530 async def test_affected_sections_extracted(
531 self, db_session: AsyncSession
532 ) -> None:
533 repo_id = await _db_repo(db_session)
534 await _db_commit(
535 db_session, repo_id, branch="main",
536 message="rewrite bridge and chorus transition"
537 )
538 await _db_commit(
539 db_session, repo_id, branch="feat",
540 message="add verse outro"
541 )
542 await db_session.flush()
543
544 result = await compute_hub_divergence(
545 db_session, repo_id=repo_id, branch_a="main", branch_b="feat"
546 )
547 resp = build_proposal_diff_response("proposal-1", "feat", "main", result)
548 assert "Bridge" in resp.affected_sections or "Chorus" in resp.affected_sections
549
550 async def test_five_dimensions_in_response(
551 self, db_session: AsyncSession
552 ) -> None:
553 repo_id = await _db_repo(db_session)
554 await _db_commit(db_session, repo_id, branch="main")
555 await _db_commit(db_session, repo_id, branch="feat")
556 await db_session.flush()
557
558 result = await compute_hub_divergence(
559 db_session, repo_id=repo_id, branch_a="main", branch_b="feat"
560 )
561 resp = build_proposal_diff_response("proposal-1", "feat", "main", result)
562 assert len(resp.dimensions) == 5
563
564
565 # ===========================================================================
566 # Layer 3 — E2E
567 # ===========================================================================
568
569
570 class TestE2EDivergenceEndpoint:
571 async def test_200_with_two_branches(
572 self,
573 client: AsyncClient,
574 auth_headers: StrDict,
575 db_session: AsyncSession,
576 ) -> None:
577 repo_id = await _api_repo(client, auth_headers)
578 await _db_commit(db_session, repo_id, branch="main", message="add chord")
579 await _db_commit(db_session, repo_id, branch="feat", message="add melody")
580 await db_session.commit()
581
582 r = await client.get(
583 f"/api/repos/{repo_id}/divergence?branch_a=main&branch_b=feat",
584 headers=auth_headers,
585 )
586 assert r.status_code == 200
587 body = r.json()
588 assert "repoId" in body
589 assert "dimensions" in body
590 assert len(body["dimensions"]) == 5
591 assert "overallScore" in body
592
593 async def test_404_unknown_repo(
594 self,
595 client: AsyncClient,
596 auth_headers: StrDict,
597 ) -> None:
598 r = await client.get(
599 "/api/repos/nonexistent/divergence?branch_a=main&branch_b=feat",
600 headers=auth_headers,
601 )
602 assert r.status_code == 404
603
604 async def test_422_empty_branch(
605 self,
606 client: AsyncClient,
607 auth_headers: StrDict,
608 db_session: AsyncSession,
609 ) -> None:
610 repo_id = await _api_repo(client, auth_headers)
611 await _db_commit(db_session, repo_id, branch="main")
612 await db_session.commit()
613
614 r = await client.get(
615 f"/api/repos/{repo_id}/divergence?branch_a=main&branch_b=nonexistent",
616 headers=auth_headers,
617 )
618 assert r.status_code == 422
619
620 async def test_common_ancestor_field_in_response(
621 self,
622 client: AsyncClient,
623 auth_headers: StrDict,
624 db_session: AsyncSession,
625 ) -> None:
626 """commonAncestor field is always present in the response (may be null)."""
627 repo_id = await _api_repo(client, auth_headers)
628 await _db_commit(db_session, repo_id, branch="main", message="main work")
629 await _db_commit(db_session, repo_id, branch="feat", message="feat work")
630 await db_session.commit()
631
632 r = await client.get(
633 f"/api/repos/{repo_id}/divergence?branch_a=main&branch_b=feat",
634 headers=auth_headers,
635 )
636 assert r.status_code == 200
637 body = r.json()
638 assert "commonAncestor" in body
639
640 async def test_private_repo_requires_auth(
641 self,
642 client: AsyncClient,
643 db_session: AsyncSession,
644 ) -> None:
645 repo_id = await _db_repo(db_session, visibility="private")
646 await _db_commit(db_session, repo_id, branch="main")
647 await _db_commit(db_session, repo_id, branch="feat")
648 await db_session.commit()
649
650 r = await client.get(
651 f"/api/repos/{repo_id}/divergence?branch_a=main&branch_b=feat"
652 )
653 assert r.status_code in (401, 403, 404)
654
655
656 # ===========================================================================
657 # Layer 4 — Stress
658 # ===========================================================================
659
660
661 class TestStress:
662 async def test_50_commits_per_branch(self, db_session: AsyncSession) -> None:
663 repo_id = await _db_repo(db_session)
664 ts = datetime(2026, 1, 1, tzinfo=timezone.utc)
665 messages = [
666 "add melody", "fix chord", "drum beat", "bridge section",
667 "mix reverb", "update readme"
668 ]
669 for i in range(50):
670 await _db_commit(
671 db_session, repo_id, branch="main",
672 message=messages[i % len(messages)],
673 ts=ts + timedelta(minutes=i),
674 )
675 for i in range(50):
676 await _db_commit(
677 db_session, repo_id, branch="feat",
678 message=messages[(i + 2) % len(messages)],
679 ts=ts + timedelta(minutes=i),
680 )
681 await db_session.flush()
682
683 result = await compute_hub_divergence(
684 db_session, repo_id=repo_id, branch_a="main", branch_b="feat"
685 )
686 assert len(result.dimensions) == 5
687 assert 0.0 <= result.overall_score <= 1.0
688
689 async def test_5_concurrent_divergence_computes(
690 self, db_session: AsyncSession
691 ) -> None:
692 repo_id = await _db_repo(db_session)
693 await _db_commit(db_session, repo_id, branch="main", message="chord")
694 await _db_commit(db_session, repo_id, branch="feat", message="melody")
695 await db_session.flush()
696
697 results = await asyncio.gather(
698 *[
699 compute_hub_divergence(
700 db_session, repo_id=repo_id, branch_a="main", branch_b="feat"
701 )
702 for _ in range(5)
703 ]
704 )
705 assert all(isinstance(r, MuseHubDivergenceResult) for r in results)
706
707 async def test_dimension_divergence_1000_calls(self) -> None:
708 """compute_hub_dimension_divergence is pure — 1000 calls must complete fast."""
709 a_ids = {f"c{i}" for i in range(20)}
710 b_ids = {f"c{i + 10}" for i in range(20)}
711 a_msgs = {f"c{i}": "add melody chord" for i in range(20)}
712 b_msgs = {f"c{i + 10}": "drum groove beat" for i in range(20)}
713
714 start = time.perf_counter()
715 for _ in range(1000):
716 compute_hub_dimension_divergence("melodic", a_ids, b_ids, a_msgs, b_msgs)
717 elapsed = time.perf_counter() - start
718 assert elapsed < 1.0, f"1000 dimension calls took {elapsed:.3f}s"
719
720
721 # ===========================================================================
722 # Layer 5 — Data Integrity
723 # ===========================================================================
724
725
726 class TestDataIntegrity:
727 def test_score_always_in_0_1(self) -> None:
728 for a_size in range(5):
729 for b_size in range(5):
730 a_ids = {f"a{i}" for i in range(a_size)}
731 b_ids = {f"b{i}" for i in range(b_size)}
732 a_msgs = {f"a{i}": "add melody" for i in range(a_size)}
733 b_msgs = {f"b{i}": "add melody" for i in range(b_size)}
734 result = compute_hub_dimension_divergence(
735 "melodic", a_ids, b_ids, a_msgs, b_msgs
736 )
737 assert 0.0 <= result.score <= 1.0
738
739 def test_score_symmetric(self) -> None:
740 """score(A, B) == score(B, A)."""
741 a_ids = {"c1", "c2"}
742 b_ids = {"c3", "c4"}
743 msgs = {
744 "c1": "add melody",
745 "c2": "melody riff",
746 "c3": "add melody",
747 "c4": "chord melody",
748 }
749 r_ab = compute_hub_dimension_divergence("melodic", a_ids, b_ids, msgs, msgs)
750 r_ba = compute_hub_dimension_divergence("melodic", b_ids, a_ids, msgs, msgs)
751 assert r_ab.score == r_ba.score
752
753 async def test_overall_score_mean_of_five(
754 self, db_session: AsyncSession
755 ) -> None:
756 repo_id = await _db_repo(db_session)
757 await _db_commit(
758 db_session, repo_id, branch="main",
759 message="add chord melody rhythm mix structure"
760 )
761 await _db_commit(
762 db_session, repo_id, branch="feat",
763 message="remove chord melody"
764 )
765 await db_session.flush()
766
767 result = await compute_hub_divergence(
768 db_session, repo_id=repo_id, branch_a="main", branch_b="feat"
769 )
770 expected_mean = round(sum(d.score for d in result.dimensions) / 5, 4)
771 assert abs(result.overall_score - expected_mean) < 1e-6
772
773 async def test_identical_branches_all_scores_zero(
774 self, db_session: AsyncSession
775 ) -> None:
776 """When both branches have exactly the same commits, divergence = 0."""
777 repo_id = await _db_repo(db_session)
778 c = await _db_commit(
779 db_session, repo_id, branch="main", message="add chord melody"
780 )
781 # Add same commit on "feat" branch (same commit_id, different branch field)
782 c2 = MusehubCommit(
783 commit_id=_cid(),
784 repo_id=repo_id,
785 branch="feat",
786 parent_ids=[c.commit_id],
787 message="add chord melody",
788 author="agent",
789 timestamp=datetime.now(timezone.utc),
790 )
791 db_session.add(c2)
792 await db_session.flush()
793
794 result = await compute_hub_divergence(
795 db_session, repo_id=repo_id, branch_a="main", branch_b="feat"
796 )
797 assert len(result.dimensions) == 5
798
799 def test_extract_affected_sections_stable_order(self) -> None:
800 # Keywords appear in keyword-order as defined by _SECTION_RE
801 msgs = ("bridge chorus verse intro outro",)
802 sections = extract_affected_sections(msgs)
803 assert len(sections) == 5
804 # Verify no duplicates
805 assert len(sections) == len(set(sections))
806
807
808 # ===========================================================================
809 # Layer 6 — Security
810 # ===========================================================================
811
812
813 class TestSecurity:
814 async def test_private_repo_blocked_without_auth(
815 self,
816 client: AsyncClient,
817 db_session: AsyncSession,
818 ) -> None:
819 repo_id = await _db_repo(db_session, visibility="private")
820 await _db_commit(db_session, repo_id, branch="main")
821 await _db_commit(db_session, repo_id, branch="feat")
822 await db_session.commit()
823
824 r = await client.get(
825 f"/api/repos/{repo_id}/divergence?branch_a=main&branch_b=feat"
826 )
827 assert r.status_code in (401, 403, 404)
828
829 async def test_sql_injection_in_branch_name_safe(
830 self,
831 client: AsyncClient,
832 auth_headers: StrDict,
833 db_session: AsyncSession,
834 ) -> None:
835 repo_id = await _api_repo(client, auth_headers)
836 await _db_commit(db_session, repo_id, branch="main")
837 await db_session.commit()
838
839 r = await client.get(
840 f"/api/repos/{repo_id}/divergence"
841 "?branch_a=main&branch_b='; DROP TABLE musehub_commits; --",
842 headers=auth_headers,
843 )
844 # parameterized query — returns 422 (no commits) not 500
845 assert r.status_code in (422, 404)
846
847 def test_classify_message_no_injection_risk(self) -> None:
848 """classify_message on arbitrary strings must not raise."""
849 payloads = [
850 "'; DROP TABLE x; --",
851 "<script>alert(1)</script>",
852 "\x00\x01\x02",
853 "A" * 10000,
854 ]
855 for p in payloads:
856 result = classify_message(p)
857 assert isinstance(result, set)
858
859 def test_score_to_level_boundary_exhaustive(self) -> None:
860 """All scores in [0, 1] map to a valid level — no crashes."""
861 for i in range(101):
862 score = i / 100
863 level = score_to_level(score)
864 assert level in MuseHubDivergenceLevel
865
866
867 # ===========================================================================
868 # Layer 7 — Performance
869 # ===========================================================================
870
871
872 class TestPerformance:
873 async def test_compute_hub_divergence_30_commits_under_200ms(
874 self, db_session: AsyncSession
875 ) -> None:
876 repo_id = await _db_repo(db_session)
877 ts = datetime(2026, 1, 1, tzinfo=timezone.utc)
878 for i in range(15):
879 await _db_commit(
880 db_session, repo_id, branch="main",
881 message=f"commit {i} chord melody",
882 ts=ts + timedelta(minutes=i),
883 )
884 for i in range(15):
885 await _db_commit(
886 db_session, repo_id, branch="feat",
887 message=f"feat {i} drum groove",
888 ts=ts + timedelta(minutes=i),
889 )
890 await db_session.flush()
891
892 start = time.perf_counter()
893 result = await compute_hub_divergence(
894 db_session, repo_id=repo_id, branch_a="main", branch_b="feat"
895 )
896 elapsed = time.perf_counter() - start
897
898 assert result is not None
899 assert elapsed < 0.2, f"compute_hub_divergence took {elapsed:.3f}s"
900
901 def test_classify_message_under_1ms(self) -> None:
902 msg = "add jazzy chord melody with reverb and bridge arrangement"
903 start = time.perf_counter()
904 for _ in range(10_000):
905 classify_message(msg)
906 elapsed = time.perf_counter() - start
907 assert elapsed < 1.0, f"10000 classify_message calls took {elapsed:.3f}s"
908
909 def test_find_common_ancestor_100_commits_fast(self) -> None:
910 # Build 100 commits on each branch sharing first 50
911 shared = [_stub_commit(f"s{i}", [f"s{i-1}"] if i > 0 else []) for i in range(50)]
912 a_only = [_stub_commit(f"a{i}", [f"s{49}"]) for i in range(50)]
913 b_only = [_stub_commit(f"b{i}", [f"s{49}"]) for i in range(50)]
914
915 a_commits = list(reversed(a_only)) + list(reversed(shared))
916 b_commits = list(reversed(b_only)) + list(reversed(shared))
917
918 start = time.perf_counter()
919 for _ in range(100):
920 find_common_ancestor(a_commits, b_commits)
921 elapsed = time.perf_counter() - start
922 assert elapsed < 0.5, f"100 find_common_ancestor calls took {elapsed:.3f}s"
File History 1 commit
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor 142 days ago