"""TDD — POST /repos/{repo_id}/proposals/{proposal_id}/reopen Bug / gap --------- A proposal whose merge produced a corrupt commit_id (bug #36) needs to be reset to ``open`` so the author can re-trigger the merge after the fix is deployed. No reopen endpoint exists today. Acceptance criteria ------------------- R1 POST reopen on a merged proposal → 200, state="open", mergeCommitId=None, mergedAt=None. R2 POST reopen on a closed proposal → 200, state="open". R3 POST reopen on an already-open proposal → 409. R4 Endpoint requires valid MSign auth → 401 without headers. R5 Unknown proposal_id → 404. """ from __future__ import annotations from datetime import datetime, timezone import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from muse.core.types import fake_id from musehub.core.genesis import compute_branch_id from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef from musehub.db.musehub_social_models import MusehubProposal from musehub.types.json_types import StrDict # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- async def _create_repo(client: AsyncClient, auth_headers: StrDict, name: str) -> str: resp = await client.post( "/api/repos", json={"name": name, "owner": "testuser", "initialize": False}, headers=auth_headers, ) assert resp.status_code == 201, resp.text return str(resp.json()["repoId"]) async def _seed_proposal( db: AsyncSession, repo_id: str, *, state: str = "merged", merge_commit_id: str | None = None, ) -> str: """Insert branches + a proposal row directly so we can control its state.""" for branch in ("main", "feat/x"): commit_id = fake_id(f"{repo_id}{branch}") db.add(MusehubCommit( commit_id=commit_id, branch=branch, parent_ids=[], message=f"initial on {branch}", author="aaronrene", timestamp=datetime.now(tz=timezone.utc), )) db.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id)) db.add(MusehubBranch( branch_id=compute_branch_id(repo_id, branch), repo_id=repo_id, name=branch, head_commit_id=commit_id, )) proposal_id = fake_id(f"{repo_id}-proposal") db.add(MusehubProposal( proposal_id=proposal_id, proposal_number=1, repo_id=repo_id, title="feat: some work", body="", state=state, from_branch="feat/x", to_branch="main", author="aaronrene", merge_commit_id=merge_commit_id, merged_at=datetime.now(tz=timezone.utc) if state == "merged" else None, )) await db.commit() return proposal_id # --------------------------------------------------------------------------- # R1 — reopen a merged proposal clears merge fields and sets state open # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_r1_reopen_merged_proposal( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """R1: POST reopen on a merged proposal returns 200 with state='open' and cleared merge fields. RED: endpoint does not exist (404 / 405). GREEN: service sets state='open', merge_commit_id=None, merged_at=None. """ repo_id = await _create_repo(client, auth_headers, "reopen-merged-repo") proposal_id = await _seed_proposal( db_session, repo_id, state="merged", merge_commit_id=fake_id("bad-merge"), ) resp = await client.post( f"/api/repos/{repo_id}/proposals/{proposal_id}/reopen", headers=auth_headers, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["state"] == "open" assert body["mergeCommitId"] is None assert body["mergedAt"] is None # --------------------------------------------------------------------------- # R2 — reopen a closed proposal # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_r2_reopen_closed_proposal( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """R2: POST reopen on a closed (not merged) proposal also returns 200.""" repo_id = await _create_repo(client, auth_headers, "reopen-closed-repo") proposal_id = await _seed_proposal(db_session, repo_id, state="closed") resp = await client.post( f"/api/repos/{repo_id}/proposals/{proposal_id}/reopen", headers=auth_headers, ) assert resp.status_code == 200, resp.text assert resp.json()["state"] == "open" # --------------------------------------------------------------------------- # R3 — reopening an already-open proposal is a 409 # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_r3_reopen_already_open_returns_409( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """R3: POST reopen on an open proposal returns 409 — nothing to reopen.""" repo_id = await _create_repo(client, auth_headers, "reopen-open-repo") proposal_id = await _seed_proposal(db_session, repo_id, state="open") resp = await client.post( f"/api/repos/{repo_id}/proposals/{proposal_id}/reopen", headers=auth_headers, ) assert resp.status_code == 409, resp.text # --------------------------------------------------------------------------- # R4 — auth required # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_r4_reopen_requires_auth( client: AsyncClient, db_session: AsyncSession, ) -> None: """R4: POST reopen without auth headers returns 401.""" repo_id = fake_id("no-auth-repo") proposal_id = fake_id("no-auth-proposal") resp = await client.post( f"/api/repos/{repo_id}/proposals/{proposal_id}/reopen", ) assert resp.status_code == 401 # --------------------------------------------------------------------------- # R5 — unknown proposal returns 404 # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_r5_reopen_unknown_proposal_returns_404( client: AsyncClient, auth_headers: StrDict, ) -> None: """R5: POST reopen on an unknown proposal_id returns 404.""" repo_id = fake_id("ghost-repo") proposal_id = fake_id("ghost-proposal") resp = await client.post( f"/api/repos/{repo_id}/proposals/{proposal_id}/reopen", headers=auth_headers, ) assert resp.status_code == 404