gabriel / musehub public

test_proposal_reopen.py file-level

at sha256:3 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:0 fix: fall back to any indexed mpack in read_object_bytes when push mpac… · gabriel · Jun 17, 2026
1 """TDD — POST /repos/{repo_id}/proposals/{proposal_id}/reopen
2
3 Bug / gap
4 ---------
5 A proposal whose merge produced a corrupt commit_id (bug #36) needs to be
6 reset to ``open`` so the author can re-trigger the merge after the fix is
7 deployed. No reopen endpoint exists today.
8
9 Acceptance criteria
10 -------------------
11 R1 POST reopen on a merged proposal → 200, state="open",
12 mergeCommitId=None, mergedAt=None.
13 R2 POST reopen on a closed proposal → 200, state="open".
14 R3 POST reopen on an already-open proposal → 409.
15 R4 Endpoint requires valid MSign auth → 401 without headers.
16 R5 Unknown proposal_id → 404.
17 """
18 from __future__ import annotations
19
20 from datetime import datetime, timezone
21
22 import pytest
23 from httpx import AsyncClient
24 from sqlalchemy.ext.asyncio import AsyncSession
25
26 from muse.core.types import fake_id
27 from musehub.core.genesis import compute_branch_id
28 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef
29 from musehub.db.musehub_social_models import MusehubProposal
30 from musehub.types.json_types import StrDict
31
32
33 # ---------------------------------------------------------------------------
34 # Helpers
35 # ---------------------------------------------------------------------------
36
37
38 async def _create_repo(client: AsyncClient, auth_headers: StrDict, name: str) -> str:
39 resp = await client.post(
40 "/api/repos",
41 json={"name": name, "owner": "testuser", "initialize": False},
42 headers=auth_headers,
43 )
44 assert resp.status_code == 201, resp.text
45 return str(resp.json()["repoId"])
46
47
48 async def _seed_proposal(
49 db: AsyncSession,
50 repo_id: str,
51 *,
52 state: str = "merged",
53 merge_commit_id: str | None = None,
54 ) -> str:
55 """Insert branches + a proposal row directly so we can control its state."""
56 for branch in ("main", "feat/x"):
57 commit_id = fake_id(f"{repo_id}{branch}")
58 db.add(MusehubCommit(
59 commit_id=commit_id,
60 branch=branch,
61 parent_ids=[],
62 message=f"initial on {branch}",
63 author="aaronrene",
64 timestamp=datetime.now(tz=timezone.utc),
65 ))
66 db.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id))
67 db.add(MusehubBranch(
68 branch_id=compute_branch_id(repo_id, branch),
69 repo_id=repo_id,
70 name=branch,
71 head_commit_id=commit_id,
72 ))
73 proposal_id = fake_id(f"{repo_id}-proposal")
74 db.add(MusehubProposal(
75 proposal_id=proposal_id,
76 proposal_number=1,
77 repo_id=repo_id,
78 title="feat: some work",
79 body="",
80 state=state,
81 from_branch="feat/x",
82 to_branch="main",
83 author="aaronrene",
84 merge_commit_id=merge_commit_id,
85 merged_at=datetime.now(tz=timezone.utc) if state == "merged" else None,
86 ))
87 await db.commit()
88 return proposal_id
89
90
91 # ---------------------------------------------------------------------------
92 # R1 — reopen a merged proposal clears merge fields and sets state open
93 # ---------------------------------------------------------------------------
94
95
96 @pytest.mark.asyncio
97 async def test_r1_reopen_merged_proposal(
98 client: AsyncClient,
99 auth_headers: StrDict,
100 db_session: AsyncSession,
101 ) -> None:
102 """R1: POST reopen on a merged proposal returns 200 with state='open' and
103 cleared merge fields.
104
105 RED: endpoint does not exist (404 / 405).
106 GREEN: service sets state='open', merge_commit_id=None, merged_at=None.
107 """
108 repo_id = await _create_repo(client, auth_headers, "reopen-merged-repo")
109 proposal_id = await _seed_proposal(
110 db_session, repo_id,
111 state="merged",
112 merge_commit_id=fake_id("bad-merge"),
113 )
114
115 resp = await client.post(
116 f"/api/repos/{repo_id}/proposals/{proposal_id}/reopen",
117 headers=auth_headers,
118 )
119 assert resp.status_code == 200, resp.text
120 body = resp.json()
121 assert body["state"] == "open"
122 assert body["mergeCommitId"] is None
123 assert body["mergedAt"] is None
124
125
126 # ---------------------------------------------------------------------------
127 # R2 — reopen a closed proposal
128 # ---------------------------------------------------------------------------
129
130
131 @pytest.mark.asyncio
132 async def test_r2_reopen_closed_proposal(
133 client: AsyncClient,
134 auth_headers: StrDict,
135 db_session: AsyncSession,
136 ) -> None:
137 """R2: POST reopen on a closed (not merged) proposal also returns 200."""
138 repo_id = await _create_repo(client, auth_headers, "reopen-closed-repo")
139 proposal_id = await _seed_proposal(db_session, repo_id, state="closed")
140
141 resp = await client.post(
142 f"/api/repos/{repo_id}/proposals/{proposal_id}/reopen",
143 headers=auth_headers,
144 )
145 assert resp.status_code == 200, resp.text
146 assert resp.json()["state"] == "open"
147
148
149 # ---------------------------------------------------------------------------
150 # R3 — reopening an already-open proposal is a 409
151 # ---------------------------------------------------------------------------
152
153
154 @pytest.mark.asyncio
155 async def test_r3_reopen_already_open_returns_409(
156 client: AsyncClient,
157 auth_headers: StrDict,
158 db_session: AsyncSession,
159 ) -> None:
160 """R3: POST reopen on an open proposal returns 409 — nothing to reopen."""
161 repo_id = await _create_repo(client, auth_headers, "reopen-open-repo")
162 proposal_id = await _seed_proposal(db_session, repo_id, state="open")
163
164 resp = await client.post(
165 f"/api/repos/{repo_id}/proposals/{proposal_id}/reopen",
166 headers=auth_headers,
167 )
168 assert resp.status_code == 409, resp.text
169
170
171 # ---------------------------------------------------------------------------
172 # R4 — auth required
173 # ---------------------------------------------------------------------------
174
175
176 @pytest.mark.asyncio
177 async def test_r4_reopen_requires_auth(
178 client: AsyncClient,
179 db_session: AsyncSession,
180 ) -> None:
181 """R4: POST reopen without auth headers returns 401."""
182 repo_id = fake_id("no-auth-repo")
183 proposal_id = fake_id("no-auth-proposal")
184
185 resp = await client.post(
186 f"/api/repos/{repo_id}/proposals/{proposal_id}/reopen",
187 )
188 assert resp.status_code == 401
189
190
191 # ---------------------------------------------------------------------------
192 # R5 — unknown proposal returns 404
193 # ---------------------------------------------------------------------------
194
195
196 @pytest.mark.asyncio
197 async def test_r5_reopen_unknown_proposal_returns_404(
198 client: AsyncClient,
199 auth_headers: StrDict,
200 ) -> None:
201 """R5: POST reopen on an unknown proposal_id returns 404."""
202 repo_id = fake_id("ghost-repo")
203 proposal_id = fake_id("ghost-proposal")
204
205 resp = await client.post(
206 f"/api/repos/{repo_id}/proposals/{proposal_id}/reopen",
207 headers=auth_headers,
208 )
209 assert resp.status_code == 404