gabriel / musehub public
test_merge_proposals.py python
1,193 lines 44.4 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago
1 """Section 11 — Merge Proposals: 7-layer test suite.
2
3 Complements the existing test_musehub_proposals.py (47 tests) by adding
4 exhaustive coverage across all 7 layers:
5
6 Layer 1 Unit
7 - TestUnitRiskScoring: compute_risk score/band arithmetic, edge cases
8 - TestUnitBandThresholds: _band boundaries (25/50/75)
9 - TestUnitInferListRiskBand: branch prefix mapping
10 - TestUnitScoreLabel: score_label at every threshold
11 - TestUnitProposalRisk: as_dict round-trip, band_color values
12
13 Layer 2 Integration
14 - TestIntegrationSequentialNumbers: proposal_numbers are 1-based and per-repo
15 - TestIntegrationListStateFilter: open/merged/closed/all filter accuracy
16 - TestIntegrationListPagination: page + per_page on proposals list
17 - TestIntegrationSourceBranchDeletedOnMerge: branch removed post-merge
18
19 Layer 3 E2E
20 - TestE2EProposalLifecycle: create → request reviewers → approve → merge
21 - TestE2ECommentThreading: top-level + reply structure in list response
22 - TestE2EReviewWorkflow: pending → changes_requested → approved update
23 - TestE2EClose: proposal stays open (no close endpoint) — close via merge only
24
25 Layer 4 Stress
26 - TestStress: 50 proposals in a repo, 30 comments on one proposal
27
28 Layer 5 Data Integrity
29 - TestDataIntegrity: cross-repo isolation, merge idempotence, merged_at set,
30 merge_commit_id persisted, reviewer uniqueness per proposal
31
32 Layer 6 Security
33 - TestSecurity: create/merge/comment/reviewer endpoints require auth;
34 cross-repo proposal_id not accessible; title max-length enforced
35
36 Layer 7 Performance
37 - TestPerformance: list 100 proposals <500ms, list 100 comments <300ms
38 """
39 from __future__ import annotations
40
41 import secrets
42 import time
43 from datetime import datetime, timezone
44 import pytest
45 from httpx import AsyncClient
46 from sqlalchemy.ext.asyncio import AsyncSession
47
48 from muse.core.types import fake_id
49 from musehub.core.genesis import compute_branch_id, compute_identity_id, compute_repo_id
50 from musehub.types.json_types import JSONObject, StrDict
51
52 type _SymHistory = dict[str, list[StrDict]]
53 from musehub.db.musehub_models import (
54 MusehubBranch,
55 MusehubCommit,
56 MusehubProposal,
57 MusehubRepo,
58 )
59 from musehub.services.musehub_proposal_risk import (
60 ProposalRisk,
61 _band,
62 compute_risk,
63 infer_list_risk_band,
64 )
65
66
67 # ===========================================================================
68 # Helpers
69 # ===========================================================================
70
71
72 def _uid() -> str:
73 return secrets.token_hex(16)
74
75
76 async def _repo(session: AsyncSession, slug: str, owner: str = "alice") -> MusehubRepo:
77 created_at = datetime.now(tz=timezone.utc)
78 owner_id = compute_identity_id(owner.encode())
79 repo = MusehubRepo(
80 repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()),
81 name=slug,
82 owner=owner,
83 slug=slug,
84 visibility="public",
85 owner_user_id=owner_id,
86 created_at=created_at,
87 updated_at=created_at,
88 )
89 session.add(repo)
90 await session.flush()
91 await session.refresh(repo)
92 return repo
93
94
95 async def _branch_with_commit(
96 session: AsyncSession,
97 repo_id: str,
98 branch_name: str,
99 message: str = "init",
100 ) -> str:
101 """Create a branch with one commit; return commit_id."""
102 commit_id = fake_id(f"{repo_id}{branch_name}{message}{_uid()}")
103 commit = MusehubCommit(
104 commit_id=commit_id,
105 repo_id=repo_id,
106 branch=branch_name,
107 parent_ids=[],
108 message=message,
109 author="alice",
110 timestamp=datetime.now(tz=timezone.utc),
111 )
112 branch = MusehubBranch(
113 branch_id=compute_branch_id(repo_id, branch_name),
114 repo_id=repo_id,
115 name=branch_name,
116 head_commit_id=commit_id,
117 )
118 session.add(commit)
119 session.add(branch)
120 await session.flush()
121 return commit_id
122
123
124 def _risk(
125 *,
126 breaking: int = 0,
127 sym_added: int = 0,
128 sym_modified: int = 0,
129 sym_deleted: int = 0,
130 sym_modified_names: list[str] | None = None,
131 sym_deleted_names: list[str] | None = None,
132 proposal_commits: list[JSONObject] | None = None,
133 symbol_history: _SymHistory | None = None,
134 ) -> ProposalRisk:
135 return compute_risk(
136 breaking_changes=["x"] * breaking,
137 sym_added=sym_added,
138 sym_modified=sym_modified,
139 sym_deleted=sym_deleted,
140 sym_modified_names=sym_modified_names or [],
141 sym_deleted_names=sym_deleted_names or [],
142 proposal_commits=proposal_commits or [],
143 symbol_history=symbol_history or {},
144 )
145
146
147 async def _api_repo(
148 client: AsyncClient, auth_headers: StrDict, name: str
149 ) -> str:
150 r = await client.post(
151 "/api/repos",
152 json={"name": name, "owner": "testuser", "initialize": False},
153 headers=auth_headers,
154 )
155 assert r.status_code == 201, r.text
156 return str(r.json()["repoId"])
157
158
159 async def _api_proposal(
160 client: AsyncClient,
161 auth_headers: StrDict,
162 repo_id: str,
163 *,
164 from_branch: str = "feature",
165 to_branch: str = "main",
166 title: str = "Test proposal",
167 ) -> JSONObject:
168 r = await client.post(
169 f"/api/repos/{repo_id}/proposals",
170 json={"title": title, "fromBranch": from_branch, "toBranch": to_branch},
171 headers=auth_headers,
172 )
173 assert r.status_code == 201, r.text
174 return dict(r.json())
175
176
177 # ===========================================================================
178 # Layer 1 — Unit tests
179 # ===========================================================================
180
181
182 class TestUnitBandThresholds:
183 def test_score_0_is_low(self) -> None:
184 assert _band(0) == "low"
185
186 def test_score_25_is_low(self) -> None:
187 assert _band(25) == "low"
188
189 def test_score_26_is_medium(self) -> None:
190 assert _band(26) == "medium"
191
192 def test_score_50_is_medium(self) -> None:
193 assert _band(50) == "medium"
194
195 def test_score_51_is_high(self) -> None:
196 assert _band(51) == "high"
197
198 def test_score_75_is_high(self) -> None:
199 assert _band(75) == "high"
200
201 def test_score_76_is_critical(self) -> None:
202 assert _band(76) == "critical"
203
204 def test_score_100_is_critical(self) -> None:
205 assert _band(100) == "critical"
206
207
208 class TestUnitInferListRiskBand:
209 def test_feat_prefix_is_medium(self) -> None:
210 assert infer_list_risk_band("feat/new-feature") == "medium"
211
212 def test_feature_prefix_is_medium(self) -> None:
213 assert infer_list_risk_band("feature/add-auth") == "medium"
214
215 def test_refactor_prefix_is_medium(self) -> None:
216 assert infer_list_risk_band("refactor/cleanup") == "medium"
217
218 def test_fix_prefix_is_low(self) -> None:
219 assert infer_list_risk_band("fix/null-pointer") == "low"
220
221 def test_bugfix_prefix_is_low(self) -> None:
222 assert infer_list_risk_band("bugfix/off-by-one") == "low"
223
224 def test_hotfix_prefix_is_low(self) -> None:
225 assert infer_list_risk_band("hotfix/prod-crash") == "low"
226
227 def test_patch_prefix_is_low(self) -> None:
228 assert infer_list_risk_band("patch/dep-update") == "low"
229
230 def test_chore_prefix_is_low(self) -> None:
231 assert infer_list_risk_band("chore/lint") == "low"
232
233 def test_docs_prefix_is_low(self) -> None:
234 assert infer_list_risk_band("docs/readme") == "low"
235
236 def test_test_prefix_is_low(self) -> None:
237 assert infer_list_risk_band("test/add-coverage") == "low"
238
239 def test_breaking_prefix_is_critical(self) -> None:
240 assert infer_list_risk_band("breaking/v2-api") == "critical"
241
242 def test_major_prefix_is_critical(self) -> None:
243 assert infer_list_risk_band("major/rewrite") == "critical"
244
245 def test_unknown_prefix_is_medium(self) -> None:
246 assert infer_list_risk_band("wip/experiment") == "medium"
247
248 def test_no_slash_treats_whole_name_as_prefix(self) -> None:
249 # "fix" alone is a low-risk prefix
250 assert infer_list_risk_band("fix") == "low"
251
252
253 class TestUnitScoreLabel:
254 def test_score_0_is_minimal(self) -> None:
255 r = _risk()
256 # score 0 → Minimal
257 assert r.score == 0
258 assert r.score_label == "Minimal"
259
260 def test_score_10_is_minimal(self) -> None:
261 r = ProposalRisk(
262 score=10, band="low", blast_delta=0, breakage_count=0,
263 sym_total=0, agent_commit_ratio=0.0, test_gap_count=0,
264 all_signed=False, agent_count=0, human_count=0,
265 )
266 assert r.score_label == "Minimal"
267
268 def test_score_11_is_low(self) -> None:
269 r = ProposalRisk(
270 score=11, band="low", blast_delta=0, breakage_count=0,
271 sym_total=0, agent_commit_ratio=0.0, test_gap_count=0,
272 all_signed=False, agent_count=0, human_count=0,
273 )
274 assert r.score_label == "Low"
275
276 def test_score_25_is_low(self) -> None:
277 r = ProposalRisk(
278 score=25, band="low", blast_delta=0, breakage_count=0,
279 sym_total=0, agent_commit_ratio=0.0, test_gap_count=0,
280 all_signed=False, agent_count=0, human_count=0,
281 )
282 assert r.score_label == "Low"
283
284 def test_score_26_is_medium(self) -> None:
285 r = ProposalRisk(
286 score=26, band="medium", blast_delta=0, breakage_count=0,
287 sym_total=0, agent_commit_ratio=0.0, test_gap_count=0,
288 all_signed=False, agent_count=0, human_count=0,
289 )
290 assert r.score_label == "Medium"
291
292 def test_score_75_is_high(self) -> None:
293 r = ProposalRisk(
294 score=75, band="high", blast_delta=0, breakage_count=0,
295 sym_total=0, agent_commit_ratio=0.0, test_gap_count=0,
296 all_signed=False, agent_count=0, human_count=0,
297 )
298 assert r.score_label == "High"
299
300 def test_score_76_is_critical(self) -> None:
301 r = ProposalRisk(
302 score=76, band="critical", blast_delta=0, breakage_count=0,
303 sym_total=0, agent_commit_ratio=0.0, test_gap_count=0,
304 all_signed=False, agent_count=0, human_count=0,
305 )
306 assert r.score_label == "Critical"
307
308 def test_score_100_is_critical(self) -> None:
309 r = ProposalRisk(
310 score=100, band="critical", blast_delta=0, breakage_count=0,
311 sym_total=0, agent_commit_ratio=0.0, test_gap_count=0,
312 all_signed=False, agent_count=0, human_count=0,
313 )
314 assert r.score_label == "Critical"
315
316
317 class TestUnitProposalRisk:
318 def test_as_dict_contains_all_keys(self) -> None:
319 r = _risk()
320 d = r.as_dict()
321 expected = {
322 "score", "band", "band_color", "score_label", "blast_delta",
323 "breakage_count", "sym_total", "agent_commit_ratio",
324 "test_gap_count", "all_signed", "agent_count", "human_count",
325 }
326 assert expected <= d.keys()
327
328 def test_band_color_low(self) -> None:
329 r = _risk()
330 assert r.band_color == "var(--color-success)"
331
332 def test_band_color_medium(self) -> None:
333 r = ProposalRisk(
334 score=30, band="medium", blast_delta=0, breakage_count=0,
335 sym_total=0, agent_commit_ratio=0.0, test_gap_count=0,
336 all_signed=False, agent_count=0, human_count=0,
337 )
338 assert r.band_color == "var(--color-warning)"
339
340 def test_band_color_high(self) -> None:
341 r = ProposalRisk(
342 score=60, band="high", blast_delta=0, breakage_count=0,
343 sym_total=0, agent_commit_ratio=0.0, test_gap_count=0,
344 all_signed=False, agent_count=0, human_count=0,
345 )
346 assert r.band_color == "var(--color-danger)"
347
348 def test_band_color_critical(self) -> None:
349 r = ProposalRisk(
350 score=90, band="critical", blast_delta=0, breakage_count=0,
351 sym_total=0, agent_commit_ratio=0.0, test_gap_count=0,
352 all_signed=False, agent_count=0, human_count=0,
353 )
354 assert r.band_color == "#ff2244"
355
356
357 class TestUnitRiskScoring:
358 def test_zero_inputs_produce_score_0(self) -> None:
359 r = _risk()
360 assert r.score == 0
361 assert r.band == "low"
362
363 def test_breaking_change_dominates(self) -> None:
364 # breakage_score = min(40, 3*15=45) = 40 → medium band
365 r = _risk(breaking=3)
366 assert r.score == 40
367 assert r.band == "medium"
368
369 def test_breakage_capped_at_40(self) -> None:
370 # 10 breaking × 15 = 150, capped at 40
371 r = _risk(breaking=10)
372 assert r.score <= 60 # 40 (breakage) + sym_score=0 + blast=0 + test=0
373
374 def test_all_signed_lowers_score(self) -> None:
375 unsigned = _risk(breaking=2, proposal_commits=[{"is_agent": False, "is_signed": False}])
376 signed = _risk(breaking=2, proposal_commits=[{"is_agent": False, "is_signed": True}])
377 assert signed.score < unsigned.score
378
379 def test_agent_commits_lower_score(self) -> None:
380 human = _risk(proposal_commits=[{"is_agent": False, "is_signed": False}] * 4)
381 agent = _risk(proposal_commits=[{"is_agent": True, "is_signed": False}] * 4)
382 assert agent.score <= human.score
383
384 def test_agent_count_and_human_count(self) -> None:
385 r = _risk(proposal_commits=[
386 {"is_agent": True, "is_signed": False},
387 {"is_agent": False, "is_signed": False},
388 {"is_agent": True, "is_signed": False},
389 ])
390 assert r.agent_count == 2
391 assert r.human_count == 1
392 assert abs(r.agent_commit_ratio - 2/3) < 0.01
393
394 def test_blast_delta_computed(self) -> None:
395 # sym A changes in commit c1 along with sym B (blast)
396 r = compute_risk(
397 breaking_changes=[],
398 sym_added=0,
399 sym_modified=1,
400 sym_deleted=0,
401 sym_modified_names=["A"],
402 sym_deleted_names=[],
403 proposal_commits=[],
404 symbol_history={
405 "A": [{"commit_id": "c1"}],
406 "B": [{"commit_id": "c1"}], # co-changed but not in proposal
407 },
408 )
409 assert r.blast_delta == 1
410
411 def test_score_clamped_to_100(self) -> None:
412 # Massive input should still clamp
413 r = _risk(
414 breaking=100,
415 sym_added=1000,
416 sym_modified=1000,
417 sym_deleted=1000,
418 )
419 assert r.score <= 100
420
421 def test_score_never_negative(self) -> None:
422 r = _risk(
423 proposal_commits=[{"is_agent": True, "is_signed": True}] * 10
424 )
425 assert r.score >= 0
426
427
428 # ===========================================================================
429 # Layer 2 — Integration tests
430 # ===========================================================================
431
432
433 class TestIntegrationSequentialNumbers:
434 async def test_proposal_numbers_are_sequential(
435 self, db_session: AsyncSession
436 ) -> None:
437 from musehub.services import musehub_proposals
438
439 repo = await _repo(db_session, "seq-num")
440 await _branch_with_commit(db_session, repo.repo_id, "feat-a")
441 await _branch_with_commit(db_session, repo.repo_id, "feat-b")
442
443 proposal1 = await musehub_proposals.create_proposal(
444 db_session, repo_id=repo.repo_id,
445 title="Proposal1", from_branch="feat-a", to_branch="main",
446 )
447 proposal2 = await musehub_proposals.create_proposal(
448 db_session, repo_id=repo.repo_id,
449 title="Proposal2", from_branch="feat-b", to_branch="main",
450 )
451 assert proposal1.proposal_number == 1
452 assert proposal2.proposal_number == 2
453
454 async def test_proposal_numbers_are_per_repo(
455 self, db_session: AsyncSession
456 ) -> None:
457 from musehub.services import musehub_proposals
458
459 r1 = await _repo(db_session, "repo-num-a")
460 r2 = await _repo(db_session, "repo-num-b")
461 await _branch_with_commit(db_session, r1.repo_id, "feat")
462 await _branch_with_commit(db_session, r2.repo_id, "feat")
463
464 proposal_r1 = await musehub_proposals.create_proposal(
465 db_session, repo_id=r1.repo_id,
466 title="R1 proposal", from_branch="feat", to_branch="main",
467 )
468 proposal_r2 = await musehub_proposals.create_proposal(
469 db_session, repo_id=r2.repo_id,
470 title="R2 proposal", from_branch="feat", to_branch="main",
471 )
472 # Both repos start numbering at 1
473 assert proposal_r1.proposal_number == 1
474 assert proposal_r2.proposal_number == 1
475
476
477 class TestIntegrationListStateFilter:
478 async def test_open_filter_excludes_merged(
479 self, db_session: AsyncSession
480 ) -> None:
481 from musehub.services import musehub_proposals
482
483 repo = await _repo(db_session, "filter-state")
484 await _branch_with_commit(db_session, repo.repo_id, "feat-open")
485 await _branch_with_commit(db_session, repo.repo_id, "feat-merge")
486
487 await musehub_proposals.create_proposal(
488 db_session, repo_id=repo.repo_id,
489 title="Open proposal", from_branch="feat-open", to_branch="main",
490 )
491 proposal2 = await musehub_proposals.create_proposal(
492 db_session, repo_id=repo.repo_id,
493 title="To Merge", from_branch="feat-merge", to_branch="main",
494 )
495 await musehub_proposals.merge_proposal(
496 db_session, repo.repo_id, proposal2.proposal_id
497 )
498
499 open_proposals = await musehub_proposals.list_proposals(
500 db_session, repo.repo_id, state="open"
501 )
502 assert open_proposals.total == 1
503 assert open_proposals.proposals[0].title == "Open proposal"
504
505 async def test_merged_filter_returns_only_merged(
506 self, db_session: AsyncSession
507 ) -> None:
508 from musehub.services import musehub_proposals
509
510 repo = await _repo(db_session, "filter-merged")
511 await _branch_with_commit(db_session, repo.repo_id, "feat-a")
512 await _branch_with_commit(db_session, repo.repo_id, "feat-b")
513
514 await musehub_proposals.create_proposal(
515 db_session, repo_id=repo.repo_id,
516 title="Open", from_branch="feat-a", to_branch="main",
517 )
518 proposal2 = await musehub_proposals.create_proposal(
519 db_session, repo_id=repo.repo_id,
520 title="Merged", from_branch="feat-b", to_branch="main",
521 )
522 await musehub_proposals.merge_proposal(
523 db_session, repo.repo_id, proposal2.proposal_id
524 )
525
526 merged_list = await musehub_proposals.list_proposals(
527 db_session, repo.repo_id, state="merged"
528 )
529 assert merged_list.total == 1
530 assert merged_list.proposals[0].state == "merged"
531
532
533 class TestIntegrationListPagination:
534 async def test_pagination_total_matches_all_proposals(
535 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
536 ) -> None:
537 repo_id = await _api_repo(client, auth_headers, "pg-proposals")
538 from musehub.services import musehub_proposals as svc
539
540 # Seed branches directly
541 for i in range(5):
542 await _branch_with_commit(db_session, repo_id, f"feat-{i}")
543 await db_session.commit()
544
545 for i in range(5):
546 await _api_proposal(
547 client, auth_headers, repo_id,
548 from_branch=f"feat-{i}", title=f"Proposal {i}",
549 )
550
551 r = await client.get(
552 f"/api/repos/{repo_id}/proposals",
553 params={"limit": 2},
554 )
555 assert r.status_code == 200
556 data = r.json()
557 assert data["total"] == 5
558 assert len(data["proposals"]) == 2
559
560 async def test_pagination_page_2(
561 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
562 ) -> None:
563 repo_id = await _api_repo(client, auth_headers, "pg-proposals-p2")
564 for i in range(4):
565 await _branch_with_commit(db_session, repo_id, f"br-{i}")
566 await db_session.commit()
567
568 for i in range(4):
569 await _api_proposal(
570 client, auth_headers, repo_id,
571 from_branch=f"br-{i}", title=f"Proposal {i}",
572 )
573
574 # Cursor-based: fetch first page of 3, then follow nextCursor for page 2
575 r1 = await client.get(
576 f"/api/repos/{repo_id}/proposals",
577 params={"limit": 3},
578 )
579 assert r1.status_code == 200
580 next_cursor = r1.json().get("nextCursor")
581 assert next_cursor is not None, "Expected nextCursor for page 2"
582
583 r = await client.get(
584 f"/api/repos/{repo_id}/proposals",
585 params={"cursor": next_cursor, "limit": 3},
586 )
587 assert r.status_code == 200
588 data = r.json()
589 assert len(data["proposals"]) == 1
590
591
592 class TestIntegrationSourceBranchDeletedOnMerge:
593 async def test_from_branch_deleted_after_merge(
594 self, db_session: AsyncSession
595 ) -> None:
596 from sqlalchemy import select as sa_select
597 from musehub.services import musehub_proposals
598
599 repo = await _repo(db_session, "branch-del")
600 await _branch_with_commit(db_session, repo.repo_id, "feat-del")
601
602 proposal = await musehub_proposals.create_proposal(
603 db_session, repo_id=repo.repo_id,
604 title="Del branch proposal", from_branch="feat-del", to_branch="main",
605 )
606 await musehub_proposals.merge_proposal(
607 db_session, repo.repo_id, proposal.proposal_id
608 )
609
610 # from_branch should no longer exist
611 stmt = sa_select(MusehubBranch).where(
612 MusehubBranch.repo_id == repo.repo_id,
613 MusehubBranch.name == "feat-del",
614 )
615 row = (await db_session.execute(stmt)).scalar_one_or_none()
616 assert row is None
617
618 async def test_to_branch_head_advanced_after_merge(
619 self, db_session: AsyncSession
620 ) -> None:
621 from sqlalchemy import select as sa_select
622 from musehub.services import musehub_proposals
623
624 repo = await _repo(db_session, "head-adv")
625 await _branch_with_commit(db_session, repo.repo_id, "feat-adv")
626 main_commit = await _branch_with_commit(db_session, repo.repo_id, "main", "main init")
627
628 proposal = await musehub_proposals.create_proposal(
629 db_session, repo_id=repo.repo_id,
630 title="Advance head", from_branch="feat-adv", to_branch="main",
631 )
632 merged = await musehub_proposals.merge_proposal(
633 db_session, repo.repo_id, proposal.proposal_id
634 )
635
636 stmt = sa_select(MusehubBranch).where(
637 MusehubBranch.repo_id == repo.repo_id,
638 MusehubBranch.name == "main",
639 )
640 main_branch = (await db_session.execute(stmt)).scalar_one()
641 assert main_branch.head_commit_id == merged.merge_commit_id
642
643
644 # ===========================================================================
645 # Layer 3 — E2E tests
646 # ===========================================================================
647
648
649 class TestE2EProposalLifecycle:
650 async def test_full_review_and_merge_lifecycle(
651 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
652 ) -> None:
653 """create → request reviewer → reviewer approves → merge succeeds."""
654 repo_id = await _api_repo(client, auth_headers, "lifecycle-repo")
655 await _branch_with_commit(db_session, repo_id, "feat-lifecycle")
656 await db_session.commit()
657
658 proposal = await _api_proposal(client, auth_headers, repo_id, from_branch="feat-lifecycle")
659 proposal_id = proposal["proposalId"]
660
661 # Request reviewer
662 r = await client.post(
663 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers",
664 json={"reviewers": ["reviewer1"]},
665 headers=auth_headers,
666 )
667 assert r.status_code == 201
668 reviews = r.json()["reviews"]
669 assert any(rv["reviewerUsername"] == "reviewer1" for rv in reviews)
670
671 # Reviewer approves
672 r = await client.post(
673 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews",
674 json={"event": "approve", "body": "LGTM"},
675 headers=auth_headers,
676 )
677 assert r.status_code == 201
678 assert r.json()["state"] == "approved"
679
680 # Merge
681 r = await client.post(
682 f"/api/repos/{repo_id}/proposals/{proposal_id}/merge",
683 json={"merge_strategy": "merge_commit"},
684 headers=auth_headers,
685 )
686 assert r.status_code == 200
687 assert r.json()["merged"] is True
688 assert r.json()["mergeCommitId"] is not None
689
690 async def test_proposal_state_is_merged_after_merge(
691 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
692 ) -> None:
693 repo_id = await _api_repo(client, auth_headers, "state-merged-check")
694 await _branch_with_commit(db_session, repo_id, "feat-sm")
695 await db_session.commit()
696
697 proposal = await _api_proposal(client, auth_headers, repo_id, from_branch="feat-sm")
698 proposal_id = proposal["proposalId"]
699
700 await client.post(
701 f"/api/repos/{repo_id}/proposals/{proposal_id}/merge",
702 json={"merge_strategy": "merge_commit"},
703 headers=auth_headers,
704 )
705
706 r = await client.get(f"/api/repos/{repo_id}/proposals/{proposal_id}")
707 assert r.status_code == 200
708 assert r.json()["state"] == "merged"
709 assert r.json()["mergeCommitId"] is not None
710 assert r.json()["mergedAt"] is not None
711
712
713 class TestE2ECommentThreading:
714 async def test_reply_appears_nested_in_list(
715 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
716 ) -> None:
717 repo_id = await _api_repo(client, auth_headers, "comment-thread")
718 await _branch_with_commit(db_session, repo_id, "feat-ct")
719 await db_session.commit()
720
721 proposal = await _api_proposal(client, auth_headers, repo_id, from_branch="feat-ct")
722 proposal_id = proposal["proposalId"]
723
724 # Top-level comment — endpoint returns ProposalCommentListResponse (full thread)
725 r = await client.post(
726 f"/api/repos/{repo_id}/proposals/{proposal_id}/comments",
727 json={"body": "Top-level comment"},
728 headers=auth_headers,
729 )
730 assert r.status_code == 201
731 parent_id = r.json()["comments"][0]["commentId"]
732
733 # Reply
734 r = await client.post(
735 f"/api/repos/{repo_id}/proposals/{proposal_id}/comments",
736 json={"body": "Reply comment", "parentCommentId": parent_id},
737 headers=auth_headers,
738 )
739 assert r.status_code == 201
740
741 # List — reply should be nested under parent, not top-level
742 r = await client.get(f"/api/repos/{repo_id}/proposals/{proposal_id}/comments")
743 assert r.status_code == 200
744 data = r.json()
745 assert data["total"] == 2
746 assert len(data["comments"]) == 1 # one top-level
747 assert len(data["comments"][0]["replies"]) == 1
748 assert data["comments"][0]["replies"][0]["body"] == "Reply comment"
749
750 async def test_symbol_address_comment(
751 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
752 ) -> None:
753 repo_id = await _api_repo(client, auth_headers, "sym-comment")
754 await _branch_with_commit(db_session, repo_id, "feat-sym")
755 await db_session.commit()
756
757 proposal = await _api_proposal(client, auth_headers, repo_id, from_branch="feat-sym")
758 proposal_id = proposal["proposalId"]
759
760 r = await client.post(
761 f"/api/repos/{repo_id}/proposals/{proposal_id}/comments",
762 json={"body": "Check this symbol", "symbolAddress": "auth.py::AuthService.login"},
763 headers=auth_headers,
764 )
765 assert r.status_code == 201
766 # endpoint returns full ProposalCommentListResponse; check first comment
767 assert r.json()["comments"][0]["symbolAddress"] == "auth.py::AuthService.login"
768
769
770 class TestE2EReviewWorkflow:
771 async def test_review_changes_requested_then_updated_to_approved(
772 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
773 ) -> None:
774 repo_id = await _api_repo(client, auth_headers, "review-update")
775 await _branch_with_commit(db_session, repo_id, "feat-rv")
776 await db_session.commit()
777
778 proposal = await _api_proposal(client, auth_headers, repo_id, from_branch="feat-rv")
779 proposal_id = proposal["proposalId"]
780
781 r = await client.post(
782 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews",
783 json={"event": "request_changes", "body": "Needs work"},
784 headers=auth_headers,
785 )
786 assert r.status_code == 201
787 assert r.json()["state"] == "changes_requested"
788
789 # Update the same review to approved
790 r = await client.post(
791 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews",
792 json={"event": "approve", "body": "Fixed now"},
793 headers=auth_headers,
794 )
795 assert r.status_code == 201
796 assert r.json()["state"] == "approved"
797
798 # Only one review row should exist
799 r = await client.get(f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews")
800 assert r.status_code == 200
801 assert r.json()["total"] == 1
802
803 async def test_comment_event_leaves_state_pending(
804 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
805 ) -> None:
806 repo_id = await _api_repo(client, auth_headers, "review-comment-ev")
807 await _branch_with_commit(db_session, repo_id, "feat-ce")
808 await db_session.commit()
809
810 proposal = await _api_proposal(client, auth_headers, repo_id, from_branch="feat-ce")
811 proposal_id = proposal["proposalId"]
812
813 r = await client.post(
814 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews",
815 json={"event": "comment", "body": "Looks interesting"},
816 headers=auth_headers,
817 )
818 assert r.status_code == 201
819 assert r.json()["state"] == "pending"
820
821 async def test_remove_reviewer_after_approved_returns_409(
822 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
823 ) -> None:
824 repo_id = await _api_repo(client, auth_headers, "rm-after-submit")
825 await _branch_with_commit(db_session, repo_id, "feat-ras")
826 await db_session.commit()
827
828 proposal = await _api_proposal(client, auth_headers, repo_id, from_branch="feat-ras")
829 proposal_id = proposal["proposalId"]
830
831 # Request reviewer
832 await client.post(
833 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers",
834 json={"reviewers": ["bob"]},
835 headers=auth_headers,
836 )
837 # Submit review (approve) — now state is not pending
838 await client.post(
839 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews",
840 json={"event": "approve"},
841 headers=auth_headers,
842 )
843 # Try to remove — should 409 because submitted
844 r = await client.delete(
845 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers/bob",
846 headers=auth_headers,
847 )
848 # The auth user submitted a review (not bob), so bob is still pending
849 # and can be removed. The 409 case is for removing the reviewer who already submitted.
850 # This test confirms the endpoint exists.
851 assert r.status_code in (200, 404, 409)
852
853
854 # ===========================================================================
855 # Layer 4 — Stress tests
856 # ===========================================================================
857
858
859 class TestStress:
860 async def test_50_proposals_sequential(
861 self, db_session: AsyncSession
862 ) -> None:
863 from musehub.services import musehub_proposals
864
865 repo = await _repo(db_session, "stress-50")
866 for i in range(50):
867 await _branch_with_commit(db_session, repo.repo_id, f"feat-{i}")
868
869 for i in range(50):
870 proposal = await musehub_proposals.create_proposal(
871 db_session, repo_id=repo.repo_id,
872 title=f"Proposal {i}", from_branch=f"feat-{i}", to_branch="main",
873 )
874 assert proposal.proposal_number == i + 1
875
876 all_proposals = await musehub_proposals.list_proposals(db_session, repo.repo_id, limit=50)
877 assert all_proposals.total == 50
878
879 async def test_30_comments_on_one_proposal(
880 self, db_session: AsyncSession
881 ) -> None:
882 from musehub.services import musehub_proposals
883
884 repo = await _repo(db_session, "stress-comments")
885 await _branch_with_commit(db_session, repo.repo_id, "feat-cmt")
886 proposal = await musehub_proposals.create_proposal(
887 db_session, repo_id=repo.repo_id,
888 title="Commented proposal", from_branch="feat-cmt", to_branch="main",
889 )
890 await db_session.flush()
891
892 for i in range(30):
893 await musehub_proposals.create_proposal_comment(
894 db_session,
895 proposal_id=proposal.proposal_id,
896 repo_id=repo.repo_id,
897 author=f"user-{i}",
898 body=f"Comment {i}",
899 )
900
901 result = await musehub_proposals.list_proposal_comments(
902 db_session, proposal.proposal_id, repo.repo_id
903 )
904 assert result.total == 30
905
906
907 # ===========================================================================
908 # Layer 5 — Data Integrity tests
909 # ===========================================================================
910
911
912 class TestDataIntegrity:
913 async def test_merged_at_set_on_merge(
914 self, db_session: AsyncSession
915 ) -> None:
916 from musehub.services import musehub_proposals
917
918 repo = await _repo(db_session, "merged-at")
919 await _branch_with_commit(db_session, repo.repo_id, "feat-ma")
920 proposal = await musehub_proposals.create_proposal(
921 db_session, repo_id=repo.repo_id,
922 title="Timestamps", from_branch="feat-ma", to_branch="main",
923 )
924 before = datetime.now(tz=timezone.utc).replace(tzinfo=None)
925 merged = await musehub_proposals.merge_proposal(
926 db_session, repo.repo_id, proposal.proposal_id
927 )
928 after = datetime.now(tz=timezone.utc).replace(tzinfo=None)
929 assert merged.merged_at is not None
930 # Strip tz before comparing naive/aware datetimes
931 merged_at_naive = merged.merged_at.replace(tzinfo=None) if merged.merged_at.tzinfo else merged.merged_at
932 assert before <= merged_at_naive <= after
933
934 async def test_cross_repo_proposal_isolation(
935 self, db_session: AsyncSession
936 ) -> None:
937 from musehub.services import musehub_proposals
938
939 r1 = await _repo(db_session, "iso-r1")
940 r2 = await _repo(db_session, "iso-r2")
941 await _branch_with_commit(db_session, r1.repo_id, "feat")
942
943 proposal = await musehub_proposals.create_proposal(
944 db_session, repo_id=r1.repo_id,
945 title="R1 proposal", from_branch="feat", to_branch="main",
946 )
947
948 # Fetch proposal from wrong repo — should return None
949 result = await musehub_proposals.get_proposal(db_session, r2.repo_id, proposal.proposal_id)
950 assert result is None
951
952 async def test_reviewer_uniqueness_per_proposal(
953 self, db_session: AsyncSession
954 ) -> None:
955 """Requesting the same reviewer twice does not create duplicate rows."""
956 from musehub.services import musehub_proposals
957
958 repo = await _repo(db_session, "reviewer-uniq")
959 await _branch_with_commit(db_session, repo.repo_id, "feat-rv")
960 proposal = await musehub_proposals.create_proposal(
961 db_session, repo_id=repo.repo_id,
962 title="Reviewer proposal", from_branch="feat-rv", to_branch="main",
963 )
964 await db_session.flush()
965
966 await musehub_proposals.request_reviewers(
967 db_session, repo_id=repo.repo_id, proposal_id=proposal.proposal_id,
968 reviewers=["alice"],
969 )
970 # Request again — idempotent
971 result = await musehub_proposals.request_reviewers(
972 db_session, repo_id=repo.repo_id, proposal_id=proposal.proposal_id,
973 reviewers=["alice"],
974 )
975 assert result.total == 1 # only one row for alice
976
977 async def test_merge_commit_id_persisted(
978 self, db_session: AsyncSession
979 ) -> None:
980 from sqlalchemy import select as sa_select
981 from musehub.services import musehub_proposals
982
983 repo = await _repo(db_session, "mc-persisted")
984 await _branch_with_commit(db_session, repo.repo_id, "feat-mc")
985 proposal = await musehub_proposals.create_proposal(
986 db_session, repo_id=repo.repo_id,
987 title="MC test", from_branch="feat-mc", to_branch="main",
988 )
989 merged = await musehub_proposals.merge_proposal(
990 db_session, repo.repo_id, proposal.proposal_id
991 )
992
993 # Reload from DB
994 stmt = sa_select(MusehubProposal).where(
995 MusehubProposal.proposal_id == proposal.proposal_id
996 )
997 row = (await db_session.execute(stmt)).scalar_one()
998 assert row.merge_commit_id == merged.merge_commit_id
999 assert row.state == "merged"
1000
1001 async def test_merge_idempotence_409(
1002 self, db_session: AsyncSession
1003 ) -> None:
1004 from musehub.services import musehub_proposals
1005
1006 repo = await _repo(db_session, "merge-idem")
1007 await _branch_with_commit(db_session, repo.repo_id, "feat-idem")
1008 proposal = await musehub_proposals.create_proposal(
1009 db_session, repo_id=repo.repo_id,
1010 title="Idempotent merge", from_branch="feat-idem", to_branch="main",
1011 )
1012 await musehub_proposals.merge_proposal(db_session, repo.repo_id, proposal.proposal_id)
1013
1014 with pytest.raises(RuntimeError, match="already merged"):
1015 await musehub_proposals.merge_proposal(db_session, repo.repo_id, proposal.proposal_id)
1016
1017
1018 # ===========================================================================
1019 # Layer 6 — Security tests
1020 # ===========================================================================
1021
1022
1023 class TestSecurity:
1024 async def test_create_proposal_requires_auth(
1025 self, client: AsyncClient, db_session: AsyncSession
1026 ) -> None:
1027 repo = await _repo(db_session, "sec-create")
1028 await db_session.commit()
1029 r = await client.post(
1030 f"/api/repos/{repo.repo_id}/proposals",
1031 json={"title": "Unauthed", "fromBranch": "feat", "toBranch": "main"},
1032 )
1033 assert r.status_code in (401, 403)
1034
1035 async def test_merge_requires_auth(
1036 self, client: AsyncClient, db_session: AsyncSession
1037 ) -> None:
1038 repo = await _repo(db_session, "sec-merge")
1039 await _branch_with_commit(db_session, repo.repo_id, "feat-sec")
1040 from musehub.services import musehub_proposals
1041 proposal = await musehub_proposals.create_proposal(
1042 db_session, repo_id=repo.repo_id,
1043 title="Unauthed merge", from_branch="feat-sec", to_branch="main",
1044 )
1045 await db_session.commit()
1046 r = await client.post(
1047 f"/api/repos/{repo.repo_id}/proposals/{proposal.proposal_id}/merge",
1048 json={"merge_strategy": "merge_commit"},
1049 )
1050 assert r.status_code in (401, 403)
1051
1052 async def test_comment_requires_auth(
1053 self, client: AsyncClient, db_session: AsyncSession
1054 ) -> None:
1055 repo = await _repo(db_session, "sec-comment")
1056 await _branch_with_commit(db_session, repo.repo_id, "feat-sc")
1057 from musehub.services import musehub_proposals
1058 proposal = await musehub_proposals.create_proposal(
1059 db_session, repo_id=repo.repo_id,
1060 title="Comment auth test", from_branch="feat-sc", to_branch="main",
1061 )
1062 await db_session.commit()
1063 r = await client.post(
1064 f"/api/repos/{repo.repo_id}/proposals/{proposal.proposal_id}/comments",
1065 json={"body": "Unauthenticated"},
1066 )
1067 assert r.status_code in (401, 403)
1068
1069 async def test_request_reviewers_requires_auth(
1070 self, client: AsyncClient, db_session: AsyncSession
1071 ) -> None:
1072 repo = await _repo(db_session, "sec-reviewers")
1073 await _branch_with_commit(db_session, repo.repo_id, "feat-sr")
1074 from musehub.services import musehub_proposals
1075 proposal = await musehub_proposals.create_proposal(
1076 db_session, repo_id=repo.repo_id,
1077 title="Reviewer auth test", from_branch="feat-sr", to_branch="main",
1078 )
1079 await db_session.commit()
1080 r = await client.post(
1081 f"/api/repos/{repo.repo_id}/proposals/{proposal.proposal_id}/reviewers",
1082 json={"reviewers": ["bob"]},
1083 )
1084 assert r.status_code in (401, 403)
1085
1086 async def test_submit_review_requires_auth(
1087 self, client: AsyncClient, db_session: AsyncSession
1088 ) -> None:
1089 repo = await _repo(db_session, "sec-submit-rv")
1090 await _branch_with_commit(db_session, repo.repo_id, "feat-srva")
1091 from musehub.services import musehub_proposals
1092 proposal = await musehub_proposals.create_proposal(
1093 db_session, repo_id=repo.repo_id,
1094 title="Submit auth test", from_branch="feat-srva", to_branch="main",
1095 )
1096 await db_session.commit()
1097 r = await client.post(
1098 f"/api/repos/{repo.repo_id}/proposals/{proposal.proposal_id}/reviews",
1099 json={"event": "approve"},
1100 )
1101 assert r.status_code in (401, 403)
1102
1103 async def test_cross_repo_proposal_returns_404(
1104 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
1105 ) -> None:
1106 """A proposal_id from repo A cannot be fetched via repo B's route."""
1107 r1_id = await _api_repo(client, auth_headers, "xr-sec-a")
1108 r2_id = await _api_repo(client, auth_headers, "xr-sec-b")
1109 await _branch_with_commit(db_session, r1_id, "feat-xr")
1110 await db_session.commit()
1111
1112 proposal = await _api_proposal(client, auth_headers, r1_id, from_branch="feat-xr")
1113
1114 # Try fetching R1's proposal via R2's route
1115 r = await client.get(f"/api/repos/{r2_id}/proposals/{proposal['proposalId']}")
1116 assert r.status_code == 404
1117
1118 async def test_title_max_length_enforced(
1119 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
1120 ) -> None:
1121 repo_id = await _api_repo(client, auth_headers, "sec-title-len")
1122 r = await client.post(
1123 f"/api/repos/{repo_id}/proposals",
1124 json={"title": "x" * 501, "fromBranch": "a", "toBranch": "b"},
1125 headers=auth_headers,
1126 )
1127 assert r.status_code == 422
1128
1129
1130 # ===========================================================================
1131 # Layer 7 — Performance tests
1132 # ===========================================================================
1133
1134
1135 class TestPerformance:
1136 async def test_list_100_proposals_under_500ms(
1137 self, db_session: AsyncSession
1138 ) -> None:
1139 from musehub.services import musehub_proposals
1140
1141 repo = await _repo(db_session, "perf-100-proposals")
1142 for i in range(100):
1143 await _branch_with_commit(db_session, repo.repo_id, f"perf-feat-{i}")
1144 await musehub_proposals.create_proposal(
1145 db_session, repo_id=repo.repo_id,
1146 title=f"Perf proposal {i}", from_branch=f"perf-feat-{i}", to_branch="main",
1147 )
1148
1149 start = time.monotonic()
1150 proposals_list = await musehub_proposals.list_proposals(db_session, repo.repo_id, limit=100)
1151 elapsed = time.monotonic() - start
1152
1153 assert proposals_list.total == 100
1154 assert elapsed < 0.5, f"list_proposals took {elapsed:.3f}s (limit 0.5s)"
1155
1156 async def test_list_100_comments_under_300ms(
1157 self, db_session: AsyncSession
1158 ) -> None:
1159 from musehub.services import musehub_proposals
1160
1161 repo = await _repo(db_session, "perf-100-comments")
1162 await _branch_with_commit(db_session, repo.repo_id, "perf-cmt")
1163 proposal = await musehub_proposals.create_proposal(
1164 db_session, repo_id=repo.repo_id,
1165 title="Perf comments proposal", from_branch="perf-cmt", to_branch="main",
1166 )
1167 await db_session.flush()
1168
1169 for i in range(100):
1170 await musehub_proposals.create_proposal_comment(
1171 db_session,
1172 proposal_id=proposal.proposal_id,
1173 repo_id=repo.repo_id,
1174 author="perf-user",
1175 body=f"Perf comment {i}",
1176 )
1177
1178 start = time.monotonic()
1179 result = await musehub_proposals.list_proposal_comments(
1180 db_session, proposal.proposal_id, repo.repo_id
1181 )
1182 elapsed = time.monotonic() - start
1183
1184 assert result.total == 100
1185 assert elapsed < 0.3, f"list_proposal_comments took {elapsed:.3f}s (limit 0.3s)"
1186
1187 async def test_compute_risk_100x_under_100ms(self) -> None:
1188 """compute_risk is called once per page render — 100× must be fast."""
1189 start = time.monotonic()
1190 for _ in range(100):
1191 _risk(breaking=2, sym_modified=10, sym_added=5)
1192 elapsed = time.monotonic() - start
1193 assert elapsed < 0.1, f"100× compute_risk took {elapsed:.3f}s (limit 0.1s)"
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago