"""Tests for MuseHub merge proposal endpoints. Covers every acceptance criterion from issues #41, #215: - POST /repos/{repo_id}/proposals creates proposal in open state - 422 when from_branch == to_branch - 404 when from_branch does not exist - GET /proposals returns all proposals (open + merged + closed) - GET /proposals/{proposal_id} returns full proposal detail; 404 if not found - GET /proposals/{proposal_id}/diff returns five-dimension musical diff scores - GET /proposals/{proposal_id}/diff graceful degradation when branches have no commits - POST /proposals/{proposal_id}/merge creates merge commit, sets state merged - POST /proposals/{proposal_id}/merge accepts squash and rebase strategies - 409 when merging an already-merged proposal - All endpoints require valid MSign auth - affected_sections derived from commit message text, not structural score heuristic - build_proposal_diff_response / build_zero_diff_response service helpers produce valid output All tests use the shared ``client``, ``auth_headers``, and ``db_session`` fixtures from conftest.py. """ from __future__ import annotations from datetime import datetime, timezone from muse.core.types import fake_id import msgpack import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from musehub.core.genesis import compute_branch_id, compute_identity_id, compute_proposal_id, compute_repo_id from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef, MusehubSnapshot from musehub.muse_cli.snapshot import compute_commit_id, compute_snapshot_id from musehub.types.json_types import JSONObject, StrDict # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- async def _create_repo( client: AsyncClient, auth_headers: StrDict, name: str = "neo-soul-repo", ) -> str: """Create a repo via the API and return its repo_id.""" response = await client.post( "/api/repos", json={"name": name, "owner": "testuser", "initialize": False}, headers=auth_headers, ) assert response.status_code == 201 return str(response.json()["repoId"]) async def _push_branch( db: AsyncSession, repo_id: str, branch_name: str, ) -> str: """Insert a branch with one commit so the branch exists and has a head commit. Returns the commit_id so callers can reference it if needed. """ commit_id = fake_id(f"{repo_id}{branch_name}") commit = MusehubCommit( commit_id=commit_id, branch=branch_name, parent_ids=[], message=f"Initial commit on {branch_name}", author="rene", timestamp=datetime.now(tz=timezone.utc), ) branch = MusehubBranch( branch_id=compute_branch_id(repo_id, branch_name), repo_id=repo_id, name=branch_name, head_commit_id=commit_id, ) db.add(commit) db.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id)) db.add(branch) await db.commit() return commit_id async def _create_proposal_helper( client: AsyncClient, auth_headers: StrDict, repo_id: str, *, title: str = "Add neo-soul keys variation", from_branch: str = "feature", to_branch: str = "main", body: str = "", ) -> JSONObject: response = await client.post( f"/api/repos/{repo_id}/proposals", json={ "title": title, "fromBranch": from_branch, "toBranch": to_branch, "body": body, }, headers=auth_headers, ) assert response.status_code == 201, response.text return dict(response.json()) # --------------------------------------------------------------------------- # POST /repos/{repo_id}/proposals # --------------------------------------------------------------------------- async def test_create_proposal_returns_open_state( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """Proposal created via POST returns state='open' with all required fields.""" repo_id = await _create_repo(client, auth_headers, "proposal-open-state-repo") await _push_branch(db_session, repo_id, "feature") response = await client.post( f"/api/repos/{repo_id}/proposals", json={ "title": "Add neo-soul keys variation", "fromBranch": "feature", "toBranch": "main", "body": "Adds dreamy chord voicings.", }, headers=auth_headers, ) assert response.status_code == 201 body = response.json() assert body["state"] == "open" assert body["title"] == "Add neo-soul keys variation" assert body["fromBranch"] == "feature" assert body["toBranch"] == "main" assert body["body"] == "Adds dreamy chord voicings." assert "proposalId" in body assert "createdAt" in body assert body["mergeCommitId"] is None async def test_create_proposal_same_branch_returns_422( client: AsyncClient, auth_headers: StrDict, ) -> None: """Creating a proposal with from_branch == to_branch returns HTTP 422.""" repo_id = await _create_repo(client, auth_headers, "same-branch-repo") response = await client.post( f"/api/repos/{repo_id}/proposals", json={"title": "Bad proposal", "fromBranch": "main", "toBranch": "main"}, headers=auth_headers, ) assert response.status_code == 422 async def test_create_proposal_missing_from_branch_returns_404( client: AsyncClient, auth_headers: StrDict, ) -> None: """Creating a proposal when from_branch does not exist returns HTTP 404.""" repo_id = await _create_repo(client, auth_headers, "no-branch-repo") response = await client.post( f"/api/repos/{repo_id}/proposals", json={"title": "Ghost proposal", "fromBranch": "nonexistent", "toBranch": "main"}, headers=auth_headers, ) assert response.status_code == 404 async def test_create_proposal_requires_auth(client: AsyncClient) -> None: """POST /proposals returns 401 without a MSign Authorization header.""" response = await client.post( "/api/repos/any-id/proposals", json={"title": "Unauthorized", "fromBranch": "feat", "toBranch": "main"}, ) assert response.status_code == 401 # --------------------------------------------------------------------------- # GET /repos/{repo_id}/proposals # --------------------------------------------------------------------------- async def test_list_proposals_returns_all_states( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """GET /proposals returns open AND merged proposals by default.""" repo_id = await _create_repo(client, auth_headers, "list-all-states-repo") await _push_branch(db_session, repo_id, "feature-a") await _push_branch(db_session, repo_id, "feature-b") await _push_branch(db_session, repo_id, "main") proposal_a = await _create_proposal_helper( client, auth_headers, repo_id, title="Open proposal", from_branch="feature-a" ) proposal_b = await _create_proposal_helper( client, auth_headers, repo_id, title="Merged proposal", from_branch="feature-b" ) # Merge proposal_b await client.post( f"/api/repos/{repo_id}/proposals/{proposal_b['proposalId']}/merge", json={"mergeStrategy": "merge_commit"}, headers=auth_headers, ) response = await client.get( f"/api/repos/{repo_id}/proposals", headers=auth_headers, ) assert response.status_code == 200 all_proposals = response.json()["proposals"] assert len(all_proposals) == 2 states = {p["state"] for p in all_proposals} assert "open" in states assert "merged" in states async def test_list_proposals_filter_by_open( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """GET /proposals?state=open returns only open proposals.""" repo_id = await _create_repo(client, auth_headers, "filter-open-repo") await _push_branch(db_session, repo_id, "feat-open") await _push_branch(db_session, repo_id, "feat-merge") await _push_branch(db_session, repo_id, "main") await _create_proposal_helper(client, auth_headers, repo_id, title="Open proposal", from_branch="feat-open") proposal_to_merge = await _create_proposal_helper( client, auth_headers, repo_id, title="Will merge", from_branch="feat-merge" ) await client.post( f"/api/repos/{repo_id}/proposals/{proposal_to_merge['proposalId']}/merge", json={"mergeStrategy": "merge_commit"}, headers=auth_headers, ) response = await client.get( f"/api/repos/{repo_id}/proposals?state=open", headers=auth_headers, ) assert response.status_code == 200 open_proposals = response.json()["proposals"] assert len(open_proposals) == 1 assert open_proposals[0]["state"] == "open" async def test_list_proposals_nonexistent_repo_returns_404_without_auth(client: AsyncClient) -> None: """GET /proposals returns 404 for non-existent repo without a token. Uses optional_token — auth is visibility-based; missing repo → 404. """ response = await client.get("/api/repos/non-existent-repo-id/proposals") assert response.status_code == 404 # --------------------------------------------------------------------------- # GET /repos/{repo_id}/proposals/{proposal_id} # --------------------------------------------------------------------------- async def test_get_proposal_returns_full_detail( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """GET /proposals/{proposal_id} returns the full proposal object.""" repo_id = await _create_repo(client, auth_headers, "get-detail-repo") await _push_branch(db_session, repo_id, "keys-variation") created = await _create_proposal_helper( client, auth_headers, repo_id, title="Keys variation", from_branch="keys-variation", body="Dreamy neo-soul voicings", ) response = await client.get( f"/api/repos/{repo_id}/proposals/{created['proposalId']}", headers=auth_headers, ) assert response.status_code == 200 body = response.json() assert body["proposalId"] == created["proposalId"] assert body["title"] == "Keys variation" assert body["body"] == "Dreamy neo-soul voicings" assert body["state"] == "open" async def test_get_proposal_unknown_id_returns_404( client: AsyncClient, auth_headers: StrDict, ) -> None: """GET /proposals/{unknown_proposal_id} returns 404.""" repo_id = await _create_repo(client, auth_headers, "get-404-repo") response = await client.get( f"/api/repos/{repo_id}/proposals/does-not-exist", headers=auth_headers, ) assert response.status_code == 404 async def test_get_proposal_nonexistent_returns_404_without_auth(client: AsyncClient) -> None: """GET /proposals/{proposal_id} returns 404 for non-existent resource without a token. Uses optional_token — auth is visibility-based; missing repo/proposal → 404. """ response = await client.get("/api/repos/non-existent-repo/proposals/non-existent-proposal") assert response.status_code == 404 # --------------------------------------------------------------------------- # POST /repos/{repo_id}/proposals/{proposal_id}/merge # --------------------------------------------------------------------------- async def test_merge_proposal_creates_merge_commit( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """Merging a proposal creates a merge commit and sets state to 'merged'.""" repo_id = await _create_repo(client, auth_headers, "merge-commit-repo") await _push_branch(db_session, repo_id, "neo-soul") await _push_branch(db_session, repo_id, "main") p = await _create_proposal_helper( client, auth_headers, repo_id, title="Neo-soul merge", from_branch="neo-soul" ) response = await client.post( f"/api/repos/{repo_id}/proposals/{p['proposalId']}/merge", json={"mergeStrategy": "merge_commit"}, headers=auth_headers, ) assert response.status_code == 200 body = response.json() assert body["merged"] is True assert "mergeCommitId" in body assert body["mergeCommitId"] is not None # Verify proposal state changed to merged detail = await client.get( f"/api/repos/{repo_id}/proposals/{p['proposalId']}", headers=auth_headers, ) assert detail.json()["state"] == "merged" assert detail.json()["mergeCommitId"] == body["mergeCommitId"] async def test_merge_already_merged_returns_409( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """Merging an already-merged proposal returns HTTP 409 Conflict.""" repo_id = await _create_repo(client, auth_headers, "double-merge-repo") await _push_branch(db_session, repo_id, "feature-dup") await _push_branch(db_session, repo_id, "main") p = await _create_proposal_helper( client, auth_headers, repo_id, title="Duplicate merge", from_branch="feature-dup" ) # First merge succeeds first = await client.post( f"/api/repos/{repo_id}/proposals/{p['proposalId']}/merge", json={"mergeStrategy": "merge_commit"}, headers=auth_headers, ) assert first.status_code == 200 # Second merge must 409 second = await client.post( f"/api/repos/{repo_id}/proposals/{p['proposalId']}/merge", json={"mergeStrategy": "merge_commit"}, headers=auth_headers, ) assert second.status_code == 409 async def test_merge_proposal_requires_auth(client: AsyncClient) -> None: """POST /proposals/{proposal_id}/merge returns 401 without a MSign Authorization header.""" response = await client.post( "/api/repos/r/proposals/p/merge", json={"mergeStrategy": "merge_commit"}, ) assert response.status_code == 401 async def test_merge_proposal_forbidden_for_non_owner( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """POST /proposals/{proposal_id}/merge as a non-owner returns 403.""" from musehub.db.musehub_repo_models import MusehubRepo _ot = datetime.now(tz=timezone.utc) _oid = compute_identity_id(b"other-owner") other_repo = MusehubRepo( repo_id=compute_repo_id(_oid, "private-merge-repo", "code", _ot.isoformat()), name="private-merge-repo", owner="other-owner", slug="private-merge-repo", visibility="private", owner_user_id=_oid, created_at=_ot, updated_at=_ot, ) db_session.add(other_repo) await db_session.commit() response = await client.post( f"/api/repos/{other_repo.repo_id}/proposals/any-proposal-id/merge", json={"mergeStrategy": "merge_commit"}, headers=auth_headers, ) assert response.status_code == 403 # --------------------------------------------------------------------------- # Regression tests — author field on proposal # --------------------------------------------------------------------------- async def test_create_proposal_author_in_response( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """POST /proposals response includes the author field (caller handle) — regression f.""" repo_id = await _create_repo(client, auth_headers, "author-proposal-repo") await _push_branch(db_session, repo_id, "feat/author-test") response = await client.post( f"/api/repos/{repo_id}/proposals", json={ "title": "Author field regression", "body": "", "fromBranch": "feat/author-test", "toBranch": "main", }, headers=auth_headers, ) assert response.status_code == 201 body = response.json() assert "author" in body assert isinstance(body["author"], str) async def test_create_proposal_author_persisted_in_list( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """Author field is persisted and returned in the proposal list endpoint — regression f.""" repo_id = await _create_repo(client, auth_headers, "author-proposal-list-repo") await _push_branch(db_session, repo_id, "feat/author-list-test") await client.post( f"/api/repos/{repo_id}/proposals", json={ "title": "Authored proposal", "body": "", "fromBranch": "feat/author-list-test", "toBranch": "main", }, headers=auth_headers, ) list_response = await client.get( f"/api/repos/{repo_id}/proposals", headers=auth_headers, ) assert list_response.status_code == 200 items = list_response.json()["proposals"] assert len(items) == 1 assert "author" in items[0] assert isinstance(items[0]["author"], str) async def test_proposal_diff_endpoint_returns_five_dimensions( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """GET /proposals/{proposal_id}/diff returns per-dimension scores for the proposal branches.""" repo_id = await _create_repo(client, auth_headers, "diff-proposal-repo") await _push_branch(db_session, repo_id, "feat/jazz-keys") proposal_resp = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/jazz-keys", to_branch="main") proposal_id = proposal_resp["proposalId"] response = await client.get( f"/api/repos/{repo_id}/proposals/{proposal_id}/diff", headers=auth_headers, ) assert response.status_code == 200 data = response.json() assert "dimensions" in data assert len(data["dimensions"]) == 5 assert data["proposalId"] == proposal_id assert data["fromBranch"] == "feat/jazz-keys" assert data["toBranch"] == "main" assert "overallScore" in data assert isinstance(data["overallScore"], float) # Every dimension must have the expected fields for dim in data["dimensions"]: assert "dimension" in dim assert dim["dimension"] in ("melodic", "harmonic", "rhythmic", "structural", "dynamic") assert "score" in dim assert 0.0 <= dim["score"] <= 1.0 assert "level" in dim assert dim["level"] in ("NONE", "LOW", "MED", "HIGH") assert "deltaLabel" in dim assert "fromBranchCommits" in dim assert "toBranchCommits" in dim async def test_proposal_diff_endpoint_404_for_unknown_proposal( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """GET /proposals/{proposal_id}/diff returns 404 when the proposal does not exist.""" repo_id = await _create_repo(client, auth_headers, "diff-404-repo") response = await client.get( f"/api/repos/{repo_id}/proposals/nonexistent-proposal-id/diff", headers=auth_headers, ) assert response.status_code == 404 async def test_proposal_diff_endpoint_graceful_when_no_commits( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """Diff endpoint returns zero scores when branches have no commits (graceful degradation). When from_branch has commits but to_branch ('main') has none, compute_hub_divergence raises ValueError. The diff endpoint must catch it and return zero-score placeholders so the proposal detail page always renders. """ from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit from musehub.db.musehub_social_models import MusehubProposal repo_id = await _create_repo(client, auth_headers, "diff-empty-repo") # Seed from_branch with a commit so the proposal can be created. _grace_branch = "feat/empty-grace" commit_id = fake_id(f"{repo_id}{_grace_branch}") commit = MusehubCommit( commit_id=commit_id, branch=_grace_branch, parent_ids=[], message=f"Initial commit on {_grace_branch}", author="musician", timestamp=datetime.now(tz=timezone.utc), ) branch = MusehubBranch( branch_id=compute_branch_id(repo_id, _grace_branch), repo_id=repo_id, name=_grace_branch, head_commit_id=commit_id, ) db_session.add(commit) db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id)) db_session.add(branch) # to_branch 'main' deliberately has NO commits — divergence will raise ValueError. _grace_now = datetime.now(tz=timezone.utc) _grace_author_id = compute_identity_id(b"musician") proposal = MusehubProposal( proposal_id=compute_proposal_id(repo_id, _grace_author_id, _grace_branch, "main", _grace_now.isoformat()), repo_id=repo_id, proposal_number=1, title="Grace proposal", body="", state="open", from_branch=_grace_branch, to_branch="main", author="musician", ) db_session.add(proposal) await db_session.flush() await db_session.refresh(proposal) proposal_id = proposal.proposal_id await db_session.commit() response = await client.get( f"/api/repos/{repo_id}/proposals/{proposal_id}/diff", headers=auth_headers, ) assert response.status_code == 200 data = response.json() assert len(data["dimensions"]) == 5 assert data["overallScore"] == 0.0 for dim in data["dimensions"]: assert dim["score"] == 0.0 assert dim["level"] == "NONE" assert dim["deltaLabel"] == "unchanged" async def test_proposal_merge_strategy_squash_accepted( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """POST /proposals/{proposal_id}/merge accepts 'squash' as a valid mergeStrategy.""" repo_id = await _create_repo(client, auth_headers, "strategy-squash-repo") await _push_branch_with_snapshot(db_session, repo_id, "main", {"file.py": fake_id("main")}) await _push_branch_with_snapshot(db_session, repo_id, "feat/squash-test", {"file.py": fake_id("feat")}) proposal_resp = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/squash-test", to_branch="main") proposal_id = proposal_resp["proposalId"] response = await client.post( f"/api/repos/{repo_id}/proposals/{proposal_id}/merge", json={"commitHistory": "squash"}, headers=auth_headers, ) assert response.status_code == 200 data = response.json() assert data["merged"] is True async def test_proposal_merge_strategy_rebase_accepted( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """POST /proposals/{proposal_id}/merge accepts 'rebase' as a valid mergeStrategy.""" repo_id = await _create_repo(client, auth_headers, "strategy-rebase-repo") await _push_branch_with_snapshot(db_session, repo_id, "main", {"file.py": fake_id("main-r")}) await _push_branch_with_snapshot(db_session, repo_id, "feat/rebase-test", {"file.py": fake_id("feat-r")}) proposal_resp = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/rebase-test", to_branch="main") proposal_id = proposal_resp["proposalId"] response = await client.post( f"/api/repos/{repo_id}/proposals/{proposal_id}/merge", json={"commitHistory": "rebase"}, headers=auth_headers, ) assert response.status_code == 200 data = response.json() assert data["merged"] is True # --------------------------------------------------------------------------- # Proposal review comments — # --------------------------------------------------------------------------- async def test_create_proposal_comment( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """POST /proposals/{proposal_id}/comments creates a comment and returns threaded list.""" repo_id = await _create_repo(client, auth_headers, "comment-create-repo") await _push_branch(db_session, repo_id, "feat/comment-test") p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/comment-test") response = await client.post( f"/api/repos/{repo_id}/proposals/{p['proposalId']}/comments", json={"body": "The bass line feels stiff — add swing.", "targetType": "general"}, headers=auth_headers, ) assert response.status_code == 201 data = response.json() assert "comments" in data assert "total" in data assert data["total"] == 1 comment = data["comments"][0] assert comment["body"] == "The bass line feels stiff — add swing." assert comment["targetType"] == "general" assert "commentId" in comment assert "createdAt" in comment async def test_list_proposal_comments_threaded( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """GET /proposals/{proposal_id}/comments returns top-level comments with nested replies.""" repo_id = await _create_repo(client, auth_headers, "comment-list-repo") await _push_branch(db_session, repo_id, "feat/list-comments") p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/list-comments") proposal_id = p["proposalId"] # Create a top-level comment create_resp = await client.post( f"/api/repos/{repo_id}/proposals/{proposal_id}/comments", json={"body": "Top-level comment.", "targetType": "general"}, headers=auth_headers, ) assert create_resp.status_code == 201 parent_id = create_resp.json()["comments"][0]["commentId"] # Reply to it reply_resp = await client.post( f"/api/repos/{repo_id}/proposals/{proposal_id}/comments", json={"body": "A reply.", "targetType": "general", "parentCommentId": parent_id}, headers=auth_headers, ) assert reply_resp.status_code == 201 # Fetch threaded list list_resp = await client.get( f"/api/repos/{repo_id}/proposals/{proposal_id}/comments", headers=auth_headers, ) assert list_resp.status_code == 200 data = list_resp.json() assert data["total"] == 2 # Only one top-level comment assert len(data["comments"]) == 1 top = data["comments"][0] assert len(top["replies"]) == 1 assert top["replies"][0]["body"] == "A reply." async def test_comment_targets_track( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """POST /comments with target_type=region stores track and beat range correctly.""" repo_id = await _create_repo(client, auth_headers, "comment-track-repo") await _push_branch(db_session, repo_id, "feat/track-comment") p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/track-comment") response = await client.post( f"/api/repos/{repo_id}/proposals/{p['proposalId']}/comments", json={ "body": "Beats 16-24 on bass feel rushed.", "targetType": "region", "targetTrack": "bass", "targetBeatStart": 16.0, "targetBeatEnd": 24.0, }, headers=auth_headers, ) assert response.status_code == 201 comment = response.json()["comments"][0] assert comment["targetType"] == "region" assert comment["targetTrack"] == "bass" assert comment["targetBeatStart"] == 16.0 assert comment["targetBeatEnd"] == 24.0 async def test_comment_requires_auth(client: AsyncClient) -> None: """POST /proposals/{proposal_id}/comments returns 401 without a MSign Authorization header.""" response = await client.post( "/api/repos/r/proposals/p/comments", json={"body": "Unauthorized attempt."}, ) assert response.status_code == 401 async def test_reply_to_comment( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """Replying to a comment creates a threaded child visible in the list.""" repo_id = await _create_repo(client, auth_headers, "comment-reply-repo") await _push_branch(db_session, repo_id, "feat/reply-test") p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/reply-test") proposal_id = p["proposalId"] parent_resp = await client.post( f"/api/repos/{repo_id}/proposals/{proposal_id}/comments", json={"body": "Original comment.", "targetType": "general"}, headers=auth_headers, ) parent_id = parent_resp.json()["comments"][0]["commentId"] reply_resp = await client.post( f"/api/repos/{repo_id}/proposals/{proposal_id}/comments", json={"body": "Reply here.", "targetType": "general", "parentCommentId": parent_id}, headers=auth_headers, ) assert reply_resp.status_code == 201 data = reply_resp.json() # Still only one top-level comment; total is 2 assert data["total"] == 2 assert len(data["comments"]) == 1 reply = data["comments"][0]["replies"][0] assert reply["body"] == "Reply here." assert reply["parentCommentId"] == parent_id # --------------------------------------------------------------------------- # Issue #384 — affected_sections and divergence service helpers # --------------------------------------------------------------------------- def test_extract_affected_sections_returns_empty_when_no_keywords() -> None: """affected_sections is empty when no commit mentions a section keyword.""" from musehub.services.musehub_divergence import extract_affected_sections messages: tuple[str, ...] = ( "add jazzy chord voicing", "fix drum quantization", "update harmonic progression", ) assert extract_affected_sections(messages) == [] def test_extract_affected_sections_returns_only_mentioned_keywords() -> None: """affected_sections lists only the sections actually named in commits.""" from musehub.services.musehub_divergence import extract_affected_sections messages: tuple[str, ...] = ( "rework the chorus melody", "add a new bridge transition", "fix drum quantization", ) result = extract_affected_sections(messages) assert "Chorus" in result assert "Bridge" in result assert "Verse" not in result assert "Intro" not in result assert "Outro" not in result def test_extract_affected_sections_case_insensitive() -> None: """Keyword matching is case-insensitive.""" from musehub.services.musehub_divergence import extract_affected_sections messages: tuple[str, ...] = ("rewrite VERSE chord progression",) result = extract_affected_sections(messages) assert result == ["Verse"] def test_extract_affected_sections_deduplicates() -> None: """The same keyword appearing in multiple commits is only returned once.""" from musehub.services.musehub_divergence import extract_affected_sections messages: tuple[str, ...] = ( "update chorus dynamics", "fix chorus timing", "tweak chorus reverb", ) result = extract_affected_sections(messages) assert result.count("Chorus") == 1 def test_build_zero_diff_response_structure() -> None: """build_zero_diff_response returns five dimensions all at score 0.0.""" from musehub.services.musehub_divergence import ALL_DIMENSIONS, build_zero_diff_response resp = build_zero_diff_response( proposal_id="proposal-abc", repo_id="repo-xyz", from_branch="feat/test", to_branch="main", ) assert resp.proposal_id == "proposal-abc" assert resp.repo_id == "repo-xyz" assert resp.from_branch == "feat/test" assert resp.to_branch == "main" assert resp.overall_score == 0.0 assert resp.common_ancestor is None assert resp.affected_sections == [] assert len(resp.dimensions) == len(ALL_DIMENSIONS) for dim in resp.dimensions: assert dim.score == 0.0 assert dim.level == "NONE" assert dim.delta_label == "unchanged" def test_build_proposal_diff_response_affected_sections_uses_commit_messages() -> None: """build_proposal_diff_response derives affected_sections from commit messages, not score heuristic.""" from musehub.services.musehub_divergence import ( MuseHubDimensionDivergence, MuseHubDivergenceLevel, MuseHubDivergenceResult, build_proposal_diff_response, ) # Structural score > 0, but NO section keyword in any commit message. structural_dim = MuseHubDimensionDivergence( dimension="structural", level=MuseHubDivergenceLevel.LOW, score=0.3, description="Minor structural divergence.", branch_a_commits=1, branch_b_commits=0, ) result = MuseHubDivergenceResult( repo_id="repo-1", branch_a="main", branch_b="feat/changes", common_ancestor="abc123", dimensions=(structural_dim,), overall_score=0.3, all_messages=("refactor arrangement flow", "update drum pattern"), ) resp = build_proposal_diff_response( proposal_id="proposal-1", from_branch="feat/changes", to_branch="main", result=result, ) # No section keyword in commit messages → empty list, even though structural score > 0 assert resp.affected_sections == [] # --------------------------------------------------------------------------- # Proposal reviewer assignment endpoints — # --------------------------------------------------------------------------- async def test_request_reviewers_creates_pending_rows( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """POST /reviewers creates pending review rows for each requested username.""" repo_id = await _create_repo(client, auth_headers, "reviewer-create-repo") await _push_branch(db_session, repo_id, "feat/reviewer-test") p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/reviewer-test") proposal_id = p["proposalId"] response = await client.post( f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers", json={"reviewers": ["alice", "bob"]}, headers=auth_headers, ) assert response.status_code == 201 data = response.json() assert "reviews" in data assert data["total"] == 2 usernames = {r["reviewerUsername"] for r in data["reviews"]} assert usernames == {"alice", "bob"} for review in data["reviews"]: assert review["state"] == "pending" assert review["submittedAt"] is None async def test_request_reviewers_idempotent( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """Re-requesting the same reviewer does not create a duplicate row.""" repo_id = await _create_repo(client, auth_headers, "reviewer-idempotent-repo") await _push_branch(db_session, repo_id, "feat/idempotent") p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/idempotent") proposal_id = p["proposalId"] await client.post( f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers", json={"reviewers": ["alice"]}, headers=auth_headers, ) # Second request for the same reviewer response = await client.post( f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers", json={"reviewers": ["alice"]}, headers=auth_headers, ) assert response.status_code == 201 assert response.json()["total"] == 1 # still only one row async def test_request_reviewers_requires_auth(client: AsyncClient) -> None: """POST /reviewers returns 401 without a MSign Authorization header.""" response = await client.post( "/api/repos/r/proposals/p/reviewers", json={"reviewers": ["alice"]}, ) assert response.status_code == 401 async def test_remove_reviewer_deletes_pending_row( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """DELETE /reviewers/{username} removes a pending reviewer assignment.""" repo_id = await _create_repo(client, auth_headers, "reviewer-delete-repo") await _push_branch(db_session, repo_id, "feat/remove-reviewer") p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/remove-reviewer") proposal_id = p["proposalId"] await client.post( f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers", json={"reviewers": ["alice", "bob"]}, headers=auth_headers, ) response = await client.delete( f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers/alice", headers=auth_headers, ) assert response.status_code == 200 data = response.json() assert data["total"] == 1 assert data["reviews"][0]["reviewerUsername"] == "bob" async def test_remove_reviewer_not_found_returns_404( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """DELETE /reviewers/{username} returns 404 when the reviewer was never requested.""" repo_id = await _create_repo(client, auth_headers, "reviewer-404-repo") await _push_branch(db_session, repo_id, "feat/remove-404") p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/remove-404") proposal_id = p["proposalId"] response = await client.delete( f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers/nobody", headers=auth_headers, ) assert response.status_code == 404 # --------------------------------------------------------------------------- # Proposal review submission endpoints — # --------------------------------------------------------------------------- async def test_list_reviews_empty_for_new_proposal( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """GET /reviews returns an empty list for a proposal with no reviews assigned.""" repo_id = await _create_repo(client, auth_headers, "reviews-empty-repo") await _push_branch(db_session, repo_id, "feat/list-reviews-empty") p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/list-reviews-empty") proposal_id = p["proposalId"] response = await client.get( f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews", headers=auth_headers, ) assert response.status_code == 200 data = response.json() assert data["total"] == 0 assert data["reviews"] == [] async def test_list_reviews_filter_by_state( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """GET /reviews?state=pending returns only pending reviews.""" repo_id = await _create_repo(client, auth_headers, "reviews-filter-repo") await _push_branch(db_session, repo_id, "feat/filter-state") p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/filter-state") proposal_id = p["proposalId"] await client.post( f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers", json={"reviewers": ["alice", "bob"]}, headers=auth_headers, ) response = await client.get( f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews?state=pending", headers=auth_headers, ) assert response.status_code == 200 data = response.json() assert data["total"] == 2 for r in data["reviews"]: assert r["state"] == "pending" async def test_submit_review_approve( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """POST /reviews with event=approve sets state to approved and records submitted_at.""" repo_id = await _create_repo(client, auth_headers, "review-approve-repo") await _push_branch(db_session, repo_id, "feat/approve-test") p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/approve-test") proposal_id = p["proposalId"] response = await client.post( f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews", json={"verdict": "approve", "body": "Sounds great — the harmonic transitions are perfect."}, headers=auth_headers, ) assert response.status_code == 201 data = response.json() assert data["state"] == "approved" assert data["submittedAt"] is not None assert "Sounds great" in (data["body"] or "") async def test_submit_review_request_changes( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """POST /reviews with event=request_changes sets state to changes_requested.""" repo_id = await _create_repo(client, auth_headers, "review-changes-repo") await _push_branch(db_session, repo_id, "feat/changes-test") p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/changes-test") proposal_id = p["proposalId"] response = await client.post( f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews", json={"verdict": "request_changes", "body": "The bridge needs more harmonic tension."}, headers=auth_headers, ) assert response.status_code == 201 data = response.json() assert data["state"] == "changes_requested" assert data["submittedAt"] is not None async def test_submit_review_updates_existing_row( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """Submitting a second review replaces the existing row state in-place.""" repo_id = await _create_repo(client, auth_headers, "review-update-repo") await _push_branch(db_session, repo_id, "feat/update-review") p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/update-review") proposal_id = p["proposalId"] # First: request changes await client.post( f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews", json={"verdict": "request_changes", "body": "Not happy with the bridge."}, headers=auth_headers, ) # After author fixes, reviewer now approves response = await client.post( f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews", json={"verdict": "approve", "body": "Looks good now!"}, headers=auth_headers, ) assert response.status_code == 201 data = response.json() assert data["state"] == "approved" # Only one review row should exist list_resp = await client.get( f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews", headers=auth_headers, ) assert list_resp.json()["total"] == 1 async def test_remove_reviewer_after_submit_returns_409( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """DELETE /reviewers/{username} returns 409 when reviewer already submitted a review. The test context handle is 'testuser'. Submitting a review via POST /reviews creates a row with that handle as reviewer_username, and state=approved. Attempting to DELETE that reviewer must return 409 because the row is no longer pending. """ reviewer_handle = "testuser" repo_id = await _create_repo(client, auth_headers, "reviewer-submitted-repo") await _push_branch(db_session, repo_id, "feat/submitted-review") p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/submitted-review") proposal_id = p["proposalId"] # Submit a review — this creates an "approved" row for the test context handle submit_resp = await client.post( f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews", json={"verdict": "approve", "body": "Approved"}, headers=auth_headers, ) assert submit_resp.status_code == 201 # Attempting to remove the reviewer whose row is already approved must return 409 response = await client.delete( f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers/{reviewer_handle}", headers=auth_headers, ) assert response.status_code == 409 async def test_submit_review_invalid_event_returns_422( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """POST /reviews with an invalid event value returns 422 Unprocessable Entity.""" repo_id = await _create_repo(client, auth_headers, "review-invalid-event-repo") await _push_branch(db_session, repo_id, "feat/invalid-event") p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/invalid-event") proposal_id = p["proposalId"] response = await client.post( f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews", json={"event": "INVALID", "body": ""}, headers=auth_headers, ) assert response.status_code == 422 async def test_submit_review_forbidden_on_private_repo_for_non_owner( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """POST /reviews on a private repo owned by another user returns 403.""" from musehub.db.musehub_repo_models import MusehubRepo _ot = datetime.now(tz=timezone.utc) _oid = compute_identity_id(b"other-owner") other_repo = MusehubRepo( repo_id=compute_repo_id(_oid, "private-review-repo", "code", _ot.isoformat()), name="private-review-repo", owner="other-owner", slug="private-review-repo", visibility="private", owner_user_id=_oid, created_at=_ot, updated_at=_ot, ) db_session.add(other_repo) await db_session.commit() response = await client.post( f"/api/repos/{other_repo.repo_id}/proposals/any-proposal-id/reviews", json={"verdict": "approve", "body": ""}, headers=auth_headers, ) assert response.status_code == 403 async def test_create_proposal_comment_forbidden_for_non_owner( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """POST /proposals/{proposal_id}/comments on a private repo owned by another user returns 403.""" from musehub.db.musehub_repo_models import MusehubRepo _ot = datetime.now(tz=timezone.utc) _oid = compute_identity_id(b"other-owner") other_repo = MusehubRepo( repo_id=compute_repo_id(_oid, "private-comment-repo", "code", _ot.isoformat()), name="private-comment-repo", owner="other-owner", slug="private-comment-repo", visibility="private", owner_user_id=_oid, created_at=_ot, updated_at=_ot, ) db_session.add(other_repo) await db_session.commit() response = await client.post( f"/api/repos/{other_repo.repo_id}/proposals/any-proposal-id/comments", json={"body": "test comment", "targetType": "general"}, headers=auth_headers, ) assert response.status_code == 403 def test_build_proposal_diff_response_affected_sections_non_empty_when_keywords_present() -> None: """build_proposal_diff_response populates affected_sections from commit message keywords.""" from musehub.services.musehub_divergence import ( MuseHubDimensionDivergence, MuseHubDivergenceLevel, MuseHubDivergenceResult, build_proposal_diff_response, ) structural_dim = MuseHubDimensionDivergence( dimension="structural", level=MuseHubDivergenceLevel.LOW, score=0.3, description="Minor structural divergence.", branch_a_commits=2, branch_b_commits=1, ) result = MuseHubDivergenceResult( repo_id="repo-2", branch_a="main", branch_b="feat/rewrite", common_ancestor="def456", dimensions=(structural_dim,), overall_score=0.3, all_messages=("add new verse section", "polish intro melody"), ) resp = build_proposal_diff_response( proposal_id="proposal-2", from_branch="feat/rewrite", to_branch="main", result=result, ) assert "Verse" in resp.affected_sections assert "Intro" in resp.affected_sections assert "Chorus" not in resp.affected_sections # --------------------------------------------------------------------------- # Regression — server-side proposal merge snapshot correctness # --------------------------------------------------------------------------- async def _push_branch_with_snapshot( db: AsyncSession, repo_id: str, branch_name: str, manifest: StrDict, message: str = "commit", parent_ids: list[str] | None = None, ) -> tuple[str, str]: """Insert a branch with one commit and a real snapshot; return (commit_id, snapshot_id).""" snapshot_id = compute_snapshot_id(manifest) now = datetime.now(tz=timezone.utc) commit_id = compute_commit_id(parent_ids or [], snapshot_id, message, now.isoformat()) snap = MusehubSnapshot( snapshot_id=snapshot_id, manifest_blob=msgpack.packb(manifest, use_bin_type=True), entry_count=len(manifest), ) commit = MusehubCommit( commit_id=commit_id, branch=branch_name, parent_ids=parent_ids or [], message=message, author="testuser", timestamp=now, snapshot_id=snapshot_id, ) branch = MusehubBranch( branch_id=compute_branch_id(repo_id, branch_name), repo_id=repo_id, name=branch_name, head_commit_id=commit_id, ) db.add(snap) db.add(commit) db.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id)) db.add(branch) await db.commit() return commit_id, snapshot_id async def test_merge_proposal_snapshot_includes_to_branch_only_files( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """Regression: merge commit snapshot must contain to_branch-only files. Bug: merge_proposal used from_head_snapshot_id verbatim as the merge commit's snapshot. When to_branch (main) had files that from_branch never touched (e.g. executor.py added after the proposal branch was cut), those files were absent from the merge commit's snapshot. A subsequent checkout would then delete executor.py from the working tree, reproducing the MuseHub incident. Expected: merge commit snapshot = from_branch manifest ∪ to_branch-only files. """ from sqlalchemy import select from musehub.db.musehub_repo_models import MusehubCommit as DbCommit from musehub.db.musehub_repo_models import MusehubSnapshot as DbSnapshot repo_id = await _create_repo(client, auth_headers, "snapshot-correctness-repo") # to_branch (main) has: database.py v1 + executor.py (added after branch diverged). to_commit, to_snap_id = await _push_branch_with_snapshot( db_session, repo_id, "main", manifest={"database.py": fake_id("db-v1"), "executor.py": fake_id("executor-fixed")}, message="main: add executor.py", ) # from_branch (feat) has: database.py v2 + new_feature.py (executor.py absent). from_commit, from_snap_id = await _push_branch_with_snapshot( db_session, repo_id, "feat/add-feature", manifest={"database.py": fake_id("db-v2"), "new_feature.py": fake_id("new-feature")}, message="feat: database v2 + new_feature.py", ) p = await _create_proposal_helper( client, auth_headers, repo_id, title="Add new feature", from_branch="feat/add-feature", to_branch="main", ) merge_resp = await client.post( f"/api/repos/{repo_id}/proposals/{p['proposalId']}/merge", json={"mergeStrategy": "merge_commit"}, headers=auth_headers, ) assert merge_resp.status_code == 200, merge_resp.text merge_commit_id: str = str(merge_resp.json()["mergeCommitId"]) # Load the merge commit's snapshot from the DB. result = await db_session.execute( select(DbCommit).where(DbCommit.commit_id == merge_commit_id) ) merge_commit = result.scalar_one_or_none() assert merge_commit is not None, "merge commit must be stored in DB" assert merge_commit.snapshot_id is not None, "merge commit must have a snapshot" snap_result = await db_session.execute( select(DbSnapshot).where(DbSnapshot.snapshot_id == merge_commit.snapshot_id) ) snap = snap_result.scalar_one_or_none() assert snap is not None, f"snapshot {merge_commit.snapshot_id[:8]} must exist in DB" from musehub.services.musehub_snapshot import get_snapshot_manifest manifest = await get_snapshot_manifest(db_session, merge_commit.snapshot_id) # from_branch-only: new_feature.py must be present. assert "new_feature.py" in manifest, ( "REGRESSION: new_feature.py (from_branch-only addition) absent from merge commit snapshot." ) # to_branch-only: executor.py must be present. assert "executor.py" in manifest, ( "REGRESSION: executor.py (to_branch-only file) absent from merge commit snapshot.\n" "The server merge_proposal used from_branch snapshot verbatim and discarded\n" "all to_branch-only changes — identical data loss to the strategy=ours bug." ) async def test_merge_proposal_snapshot_is_not_from_branch_verbatim( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """Regression: merge commit snapshot must NOT equal from_branch snapshot verbatim. If they're equal, it means to_branch-only changes were silently discarded. """ from sqlalchemy import select from musehub.db.musehub_repo_models import MusehubCommit as DbCommit repo_id = await _create_repo(client, auth_headers, "snapshot-not-verbatim-repo") await _push_branch_with_snapshot( db_session, repo_id, "main", manifest={"shared.py": fake_id("shared"), "to-only.py": fake_id("to-only-content")}, ) _, from_snap_id = await _push_branch_with_snapshot( db_session, repo_id, "feat", manifest={"shared.py": fake_id("shared"), "from-only.py": fake_id("from-only-content")}, ) p = await _create_proposal_helper( client, auth_headers, repo_id, title="Merge feat", from_branch="feat", to_branch="main", ) resp = await client.post( f"/api/repos/{repo_id}/proposals/{p['proposalId']}/merge", json={"mergeStrategy": "merge_commit"}, headers=auth_headers, ) assert resp.status_code == 200 merge_commit_id = str(resp.json()["mergeCommitId"]) result = await db_session.execute( select(DbCommit).where(DbCommit.commit_id == merge_commit_id) ) merge_commit = result.scalar_one_or_none() assert merge_commit is not None assert merge_commit.snapshot_id != from_snap_id, ( "REGRESSION: merge commit snapshot equals from_branch snapshot verbatim.\n" "to_branch-only file 'to-only.py' was silently discarded." ) async def test_merge_proposal_snapshot_id_uses_correct_formula( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """Contract: the merge commit snapshot ID must equal compute_snapshot_id(merged_manifest). This test locks down the hash formula used by the server-side merge_proposal path. If the server switches back to json.dumps or any other scheme, this test catches it immediately — before corrupt IDs reach production history. """ from sqlalchemy import select from musehub.db.musehub_repo_models import MusehubCommit as DbCommit from musehub.db.musehub_repo_models import MusehubSnapshot as DbSnapshot repo_id = await _create_repo(client, auth_headers, "snapshot-formula-contract-repo") to_manifest = { "agentception/app.py": fake_id("aaa111"), "pyproject.toml": fake_id("bbb222"), } from_manifest = { "agentception/app.py": fake_id("ccc333"), # overrides to_branch version "agentception/new_module.py": fake_id("ddd444"), } # Expected merged manifest: from_branch values take precedence; to_branch-only # files are preserved. expected_merged = {**to_manifest, **from_manifest} expected_snapshot_id = compute_snapshot_id(expected_merged) await _push_branch_with_snapshot(db_session, repo_id, "main", manifest=to_manifest) await _push_branch_with_snapshot(db_session, repo_id, "feat/formula-check", manifest=from_manifest) p = await _create_proposal_helper( client, auth_headers, repo_id, title="Formula check proposal", from_branch="feat/formula-check", to_branch="main", ) merge_resp = await client.post( f"/api/repos/{repo_id}/proposals/{p['proposalId']}/merge", json={"mergeStrategy": "merge_commit"}, headers=auth_headers, ) assert merge_resp.status_code == 200, merge_resp.text merge_commit_id = str(merge_resp.json()["mergeCommitId"]) commit_result = await db_session.execute( select(DbCommit).where(DbCommit.commit_id == merge_commit_id) ) merge_commit = commit_result.scalar_one_or_none() assert merge_commit is not None snap_result = await db_session.execute( select(DbSnapshot).where(DbSnapshot.snapshot_id == merge_commit.snapshot_id) ) snap = snap_result.scalar_one_or_none() assert snap is not None assert snap.snapshot_id == expected_snapshot_id, ( f"Merge commit snapshot ID does not match compute_snapshot_id(merged_manifest).\n" f" server produced: {snap.snapshot_id}\n" f" formula expected: {expected_snapshot_id}\n" "The server is using a different hash formula than the muse client library — " "every proposal merge will produce corrupt snapshots that fail content-hash verification." ) # --------------------------------------------------------------------------- # POST /repos/{repo_id}/proposals/{proposal_id}/close # --------------------------------------------------------------------------- async def test_close_proposal_sets_state_closed( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """POST .../close on an open proposal must set state to 'closed'.""" repo_id = await _create_repo(client, auth_headers, "close-proposal-open-repo") await _push_branch(db_session, repo_id, "feat/close-me") proposal = await _create_proposal_helper( client, auth_headers, repo_id, title="Close me", from_branch="feat/close-me", to_branch="main", ) r = await client.post( f"/api/repos/{repo_id}/proposals/{proposal['proposalId']}/close", headers=auth_headers, ) assert r.status_code == 200, r.text assert r.json()["state"] == "closed" async def test_close_proposal_already_closed_returns_409( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """POST .../close on an already-closed proposal must return 409.""" repo_id = await _create_repo(client, auth_headers, "close-proposal-409-repo") await _push_branch(db_session, repo_id, "feat/close-twice") proposal = await _create_proposal_helper( client, auth_headers, repo_id, title="Close twice", from_branch="feat/close-twice", to_branch="main", ) pid = proposal["proposalId"] await client.post(f"/api/repos/{repo_id}/proposals/{pid}/close", headers=auth_headers) r = await client.post(f"/api/repos/{repo_id}/proposals/{pid}/close", headers=auth_headers) assert r.status_code == 409, r.text async def test_close_unknown_proposal_returns_404( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """POST .../close on a nonexistent proposal_id must return 404.""" repo_id = await _create_repo(client, auth_headers, "close-proposal-404-repo") r = await client.post( f"/api/repos/{repo_id}/proposals/sha256:{'dead' * 16}/close", headers=auth_headers, ) assert r.status_code == 404, r.text async def test_close_proposal_requires_auth(client: AsyncClient) -> None: """POST .../close without auth must return 401 or 403.""" r = await client.post("/api/repos/any-repo/proposals/any-id/close") assert r.status_code in (401, 403), r.text async def test_closed_proposal_appears_in_closed_list( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """After closing, proposal must appear in GET proposals?state=closed.""" repo_id = await _create_repo(client, auth_headers, "close-proposal-list-repo") await _push_branch(db_session, repo_id, "feat/list-closed") proposal = await _create_proposal_helper( client, auth_headers, repo_id, title="List closed", from_branch="feat/list-closed", to_branch="main", ) pid = proposal["proposalId"] await client.post(f"/api/repos/{repo_id}/proposals/{pid}/close", headers=auth_headers) r = await client.get(f"/api/repos/{repo_id}/proposals?state=closed", headers=auth_headers) assert r.status_code == 200, r.text ids = [p["proposalId"] for p in r.json()["proposals"]] assert pid in ids async def test_submit_review_accepts_verdict_field( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """POST /reviews with verdict= (CLI term) must work identically to event=. The CLI sends 'verdict' because that's what reviewers think about. The server must accept both 'verdict' and 'event' (backward compat). """ repo_id = await _create_repo(client, auth_headers, "review-verdict-repo") await _push_branch(db_session, repo_id, "feat/verdict-test") p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/verdict-test") proposal_id = p["proposalId"] response = await client.post( f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews", json={"verdict": "approve", "body": "LGTM"}, headers=auth_headers, ) assert response.status_code == 201, ( f"verdict=approve must be accepted (CLI uses --verdict). " f"Got {response.status_code}: {response.text}" ) assert response.json()["state"] == "approved" async def test_submit_review_event_field_rejected( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """POST /reviews with only event= (old field name) returns 422 — use verdict.""" repo_id = await _create_repo(client, auth_headers, "review-event-reject-repo") await _push_branch(db_session, repo_id, "feat/event-reject") p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/event-reject") proposal_id = p["proposalId"] response = await client.post( f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews", json={"event": "approve", "body": "old field name"}, headers=auth_headers, ) assert response.status_code == 422, ( "Sending 'event' without 'verdict' must fail — the field is 'verdict' now" )