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