gabriel / musehub public
test_proposal_update.py python
276 lines 9.1 KB
Raw
sha256:94ef169c149a452bff7c604ded8b280b19bd477c2dabcb56972780b0b784c7aa Merge 'fix/assignee-sigil-inline' into 'dev' — proposal: As… Human 1 day ago
1 """TDD — PATCH /repos/{repo_id}/proposals/{proposal_id}
2
3 Layer 10 — Proposal Update (CRUD)
4
5 T10.1 Author can update title.
6 T10.2 Author can update body.
7 T10.3 Author can update proposal_type.
8 T10.4 Author can update merge_strategy.
9 T10.5 Partial update — only supplied fields change, others are untouched.
10 T10.6 Non-author (different authenticated user) cannot update — 403.
11 T10.7 Unauthenticated request cannot update — 401.
12 T10.8 Update a non-existent proposal returns 404.
13 T10.9 Empty patch body (no fields) returns 422.
14 """
15 from __future__ import annotations
16
17 from datetime import datetime, timezone
18
19 import pytest
20 from httpx import AsyncClient
21 from sqlalchemy.ext.asyncio import AsyncSession
22
23 from musehub.core.genesis import compute_identity_id, compute_proposal_id, compute_repo_id
24 from musehub.db.musehub_repo_models import MusehubRepo
25 from musehub.db.musehub_social_models import MusehubProposal
26 from musehub.types.json_types import StrDict
27 from muse.core.types import now_utc_iso
28
29 _seq = 0
30
31
32 async def _make_repo(db: AsyncSession, owner: str, slug: str) -> str:
33 created_at = datetime.now(tz=timezone.utc)
34 owner_id = compute_identity_id(owner.encode())
35 repo_id = compute_repo_id(owner_id, slug, "code", created_at.isoformat())
36 db.add(MusehubRepo(
37 repo_id=repo_id, name=slug, owner=owner, slug=slug,
38 visibility="public", owner_user_id=owner_id,
39 created_at=created_at, updated_at=created_at,
40 ))
41 await db.commit()
42 return str(repo_id)
43
44
45 async def _make_proposal(
46 db: AsyncSession,
47 repo_id: str,
48 *,
49 author: str = "testuser",
50 title: str = "Original title",
51 body: str = "Original body.",
52 proposal_type: str = "state_merge",
53 merge_strategy: str = "overlay",
54 ) -> MusehubProposal:
55 global _seq
56 _seq += 1
57 author_id = compute_identity_id(author.encode())
58 from_branch = f"feat/update-test-{_seq}"
59 p = MusehubProposal(
60 proposal_id=compute_proposal_id(repo_id, author_id, from_branch, "dev", now_utc_iso()),
61 repo_id=repo_id,
62 proposal_number=_seq,
63 title=title,
64 body=body,
65 state="open",
66 proposal_type=proposal_type,
67 merge_strategy=merge_strategy,
68 from_branch=from_branch,
69 to_branch="dev",
70 author=author,
71 )
72 db.add(p)
73 await db.commit()
74 return p
75
76
77 # ---------------------------------------------------------------------------
78 # T10.1 — author can update title
79 # ---------------------------------------------------------------------------
80
81 @pytest.mark.asyncio
82 async def test_T10_1_author_can_update_title(
83 client: AsyncClient,
84 db_session: AsyncSession,
85 auth_headers: StrDict,
86 ) -> None:
87 repo_id = await _make_repo(db_session, "updatedev", "t101-repo")
88 p = await _make_proposal(db_session, repo_id, title="Old title")
89
90 resp = await client.patch(
91 f"/api/repos/{repo_id}/proposals/{p.proposal_id}",
92 json={"title": "Proposal type badges and ghost object integrity fix"},
93 headers=auth_headers,
94 )
95 assert resp.status_code == 200
96 data = resp.json()
97 assert data["title"] == "Proposal type badges and ghost object integrity fix"
98
99
100 # ---------------------------------------------------------------------------
101 # T10.2 — author can update body
102 # ---------------------------------------------------------------------------
103
104 @pytest.mark.asyncio
105 async def test_T10_2_author_can_update_body(
106 client: AsyncClient,
107 db_session: AsyncSession,
108 auth_headers: StrDict,
109 ) -> None:
110 repo_id = await _make_repo(db_session, "updatedev", "t102-repo")
111 p = await _make_proposal(db_session, repo_id, body="Old body.")
112
113 resp = await client.patch(
114 f"/api/repos/{repo_id}/proposals/{p.proposal_id}",
115 json={"body": "Updated description with more detail."},
116 headers=auth_headers,
117 )
118 assert resp.status_code == 200
119 assert resp.json()["body"] == "Updated description with more detail."
120
121
122 # ---------------------------------------------------------------------------
123 # T10.3 — author can update proposal_type
124 # ---------------------------------------------------------------------------
125
126 @pytest.mark.asyncio
127 async def test_T10_3_author_can_update_proposal_type(
128 client: AsyncClient,
129 db_session: AsyncSession,
130 auth_headers: StrDict,
131 ) -> None:
132 repo_id = await _make_repo(db_session, "updatedev", "t103-repo")
133 p = await _make_proposal(db_session, repo_id, proposal_type="state_merge")
134
135 resp = await client.patch(
136 f"/api/repos/{repo_id}/proposals/{p.proposal_id}",
137 json={"proposal_type": "canonical_release"},
138 headers=auth_headers,
139 )
140 assert resp.status_code == 200
141 assert resp.json()["proposalType"] == "canonical_release"
142
143
144 # ---------------------------------------------------------------------------
145 # T10.4 — author can update merge_strategy
146 # ---------------------------------------------------------------------------
147
148 @pytest.mark.asyncio
149 async def test_T10_4_author_can_update_merge_strategy(
150 client: AsyncClient,
151 db_session: AsyncSession,
152 auth_headers: StrDict,
153 ) -> None:
154 repo_id = await _make_repo(db_session, "updatedev", "t104-repo")
155 p = await _make_proposal(db_session, repo_id, merge_strategy="overlay")
156
157 resp = await client.patch(
158 f"/api/repos/{repo_id}/proposals/{p.proposal_id}",
159 json={"merge_strategy": "replay"},
160 headers=auth_headers,
161 )
162 assert resp.status_code == 200
163 assert resp.json()["mergeStrategy"] == "replay"
164
165
166 # ---------------------------------------------------------------------------
167 # T10.5 — partial update leaves untouched fields unchanged
168 # ---------------------------------------------------------------------------
169
170 @pytest.mark.asyncio
171 async def test_T10_5_partial_update_only_changes_supplied_fields(
172 client: AsyncClient,
173 db_session: AsyncSession,
174 auth_headers: StrDict,
175 ) -> None:
176 repo_id = await _make_repo(db_session, "updatedev", "t105-repo")
177 p = await _make_proposal(
178 db_session, repo_id,
179 title="Original title",
180 body="Original body.",
181 proposal_type="state_merge",
182 )
183
184 resp = await client.patch(
185 f"/api/repos/{repo_id}/proposals/{p.proposal_id}",
186 json={"title": "New title only"},
187 headers=auth_headers,
188 )
189 assert resp.status_code == 200
190 data = resp.json()
191 assert data["title"] == "New title only"
192 assert data["body"] == "Original body."
193 assert data["proposalType"] == "state_merge"
194
195
196 # ---------------------------------------------------------------------------
197 # T10.6 — non-author cannot update (403)
198 # ---------------------------------------------------------------------------
199
200 @pytest.mark.asyncio
201 async def test_T10_6_non_author_cannot_update(
202 client: AsyncClient,
203 db_session: AsyncSession,
204 auth_headers: StrDict,
205 ) -> None:
206 # proposal authored by "someone-else"; auth_headers authenticates as "testuser"
207 repo_id = await _make_repo(db_session, "updatedev", "t106-repo")
208 p = await _make_proposal(db_session, repo_id, author="someone-else")
209
210 resp = await client.patch(
211 f"/api/repos/{repo_id}/proposals/{p.proposal_id}",
212 json={"title": "Attempted hijack"},
213 headers=auth_headers,
214 )
215 assert resp.status_code == 403
216
217
218 # ---------------------------------------------------------------------------
219 # T10.7 — unauthenticated request returns 401
220 # ---------------------------------------------------------------------------
221
222 @pytest.mark.asyncio
223 async def test_T10_7_unauthenticated_update_returns_401(
224 client: AsyncClient,
225 db_session: AsyncSession,
226 ) -> None:
227 repo_id = await _make_repo(db_session, "updatedev", "t107-repo")
228 p = await _make_proposal(db_session, repo_id)
229
230 resp = await client.patch(
231 f"/api/repos/{repo_id}/proposals/{p.proposal_id}",
232 json={"title": "No auth"},
233 )
234 assert resp.status_code == 401
235
236
237 # ---------------------------------------------------------------------------
238 # T10.8 — non-existent proposal returns 404
239 # ---------------------------------------------------------------------------
240
241 @pytest.mark.asyncio
242 async def test_T10_8_missing_proposal_returns_404(
243 client: AsyncClient,
244 db_session: AsyncSession,
245 auth_headers: StrDict,
246 ) -> None:
247 repo_id = await _make_repo(db_session, "updatedev", "t108-repo")
248 fake_id = "sha256:" + "0" * 64
249
250 resp = await client.patch(
251 f"/api/repos/{repo_id}/proposals/{fake_id}",
252 json={"title": "Ghost"},
253 headers=auth_headers,
254 )
255 assert resp.status_code == 404
256
257
258 # ---------------------------------------------------------------------------
259 # T10.9 — empty patch body returns 422
260 # ---------------------------------------------------------------------------
261
262 @pytest.mark.asyncio
263 async def test_T10_9_empty_patch_body_returns_422(
264 client: AsyncClient,
265 db_session: AsyncSession,
266 auth_headers: StrDict,
267 ) -> None:
268 repo_id = await _make_repo(db_session, "updatedev", "t109-repo")
269 p = await _make_proposal(db_session, repo_id)
270
271 resp = await client.patch(
272 f"/api/repos/{repo_id}/proposals/{p.proposal_id}",
273 json={},
274 headers=auth_headers,
275 )
276 assert resp.status_code == 422
File History 3 commits
sha256:94ef169c149a452bff7c604ded8b280b19bd477c2dabcb56972780b0b784c7aa Merge 'fix/assignee-sigil-inline' into 'dev' — proposal: As… Human 1 day ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 9 days ago
sha256:4aed3d8601c8dd3ed37074de35f11f4a9699a0a4b99d43727048fd3f8e6fd13d chore: doc sweep, ignore wrangler build state, misc fixes Sonnet 4.6 minor 12 days ago