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