gabriel / musehub public
test_proposal_snapshot_anchors.py python
341 lines 12.6 KB
Raw
sha256:7d9bf1946da951ebe5e850065339eb42dcc3f6866956f0cb8b767977d72779fb Merge 'fix/proposal-snapshot-commit-naming' into 'dev' — pr… Human 5 days ago
1 """TDD: Commit and snapshot anchors on merge proposals.
2
3 When a proposal is created, the server captures the HEAD commit ID of each
4 branch at that moment and stores them as cryptographic anchors, plus the
5 Snapshot (manifest) ID that each commit points to:
6
7 from_commit_id — sha256:<hex> of from_branch HEAD commit at proposal creation time
8 to_commit_id — sha256:<hex> of to_branch HEAD commit at proposal creation time
9 from_snapshot_id — sha256:<hex> Snapshot (manifest) ID that from_commit_id points to
10 to_snapshot_id — sha256:<hex> Snapshot (manifest) ID that to_commit_id points to
11
12 These are the "FROM STATE / TO STATE" anchors shown in the proposal detail UI
13 (linked there by commit ID, not snapshot ID).
14
15 musehub#144 review found that #0049 named the anchor columns from/to_snapshot_id
16 but always populated them with a commit ID -- every row ever written held a
17 commit ID, never a real Snapshot ID. Fixed by adding from/to_commit_id (holding
18 what from/to_snapshot_id always actually held) and correcting from/to_snapshot_id
19 to hold the real snapshot_id looked up from the anchor commit.
20
21 Acceptance criteria
22 -------------------
23 T1 POST /proposals stores from_commit_id and to_commit_id when both branches
24 have commits; GET returns both as fromCommitId / toCommitId.
25 T1b Snapshot lookup: when the anchor commit has a real snapshot_id, GET
26 returns it as fromSnapshotId / toSnapshotId.
27 T1c Snapshot lookup miss: when the anchor commit ID doesn't resolve to a real
28 MusehubCommit row (e.g. a fixture/legacy commit), fromSnapshotId /
29 toSnapshotId are null rather than falling back to the commit ID.
30 T2 POST /proposals sets from_commit_id = null when from_branch has no HEAD.
31 T3 POST /proposals sets to_commit_id = null when to_branch has no HEAD.
32 T4 fromCommitId/toCommitId/fromSnapshotId/toSnapshotId are present (possibly
33 null) on every ProposalResponse — the fields are never absent.
34 T5 Existing proposals created before this feature have null anchors —
35 backwards-compatible, no crash on GET.
36 T6 The stored from_commit_id matches the branch's head_commit_id at creation
37 time, not whatever the branch HEAD becomes later.
38 """
39 from __future__ import annotations
40
41 from datetime import datetime, timezone
42
43 import pytest
44 from httpx import AsyncClient
45 from sqlalchemy.ext.asyncio import AsyncSession
46
47 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit
48 from musehub.db.musehub_social_models import MusehubProposal
49 from musehub.core.genesis import compute_branch_id
50 from musehub.types.json_types import StrDict
51 from sqlalchemy import select
52
53
54 # ---------------------------------------------------------------------------
55 # Helpers
56 # ---------------------------------------------------------------------------
57
58 async def _create_repo(client: AsyncClient, auth_headers: StrDict, name: str) -> str:
59 r = await client.post(
60 "/api/repos",
61 json={"name": name, "owner": "testuser", "initialize": False},
62 headers=auth_headers,
63 )
64 assert r.status_code == 201
65 return str(r.json()["repoId"])
66
67
68 async def _push_branch(
69 db: AsyncSession,
70 repo_id: str,
71 branch_name: str,
72 head_commit_id: str | None = None,
73 ) -> None:
74 """Insert a branch pointing at head_commit_id, without a backing commit row.
75
76 Used for tests that only care about the commit-ID anchor, not the
77 snapshot lookup (the commit ID is a bare fixture value, not a real
78 MusehubCommit primary key).
79 """
80 branch = MusehubBranch(
81 branch_id=compute_branch_id(repo_id, branch_name),
82 repo_id=repo_id,
83 name=branch_name,
84 head_commit_id=head_commit_id,
85 )
86 db.add(branch)
87 await db.commit()
88
89
90 async def _push_branch_with_real_commit(
91 db: AsyncSession,
92 repo_id: str,
93 branch_name: str,
94 commit_id: str,
95 snapshot_id: str,
96 ) -> None:
97 """Insert a branch AND a backing MusehubCommit row with a real snapshot_id."""
98 db.add(MusehubCommit(
99 commit_id=commit_id,
100 branch=branch_name,
101 message="m",
102 author="testuser",
103 timestamp=datetime.now(tz=timezone.utc),
104 snapshot_id=snapshot_id,
105 ))
106 db.add(MusehubBranch(
107 branch_id=compute_branch_id(repo_id, branch_name),
108 repo_id=repo_id,
109 name=branch_name,
110 head_commit_id=commit_id,
111 ))
112 await db.commit()
113
114
115 _COMMIT_A = "sha256:" + "a" * 64
116 _COMMIT_B = "sha256:" + "b" * 64
117 _SNAPSHOT_A = "sha256:" + "1" * 64
118 _SNAPSHOT_B = "sha256:" + "2" * 64
119
120
121 # ---------------------------------------------------------------------------
122 # T1 — both branches have commits → commit anchors stored and returned
123 # ---------------------------------------------------------------------------
124
125 @pytest.mark.asyncio
126 async def test_commit_anchors_stored_when_both_branches_have_heads(
127 client: AsyncClient,
128 auth_headers: StrDict,
129 db_session: AsyncSession,
130 ) -> None:
131 repo_id = await _create_repo(client, auth_headers, "anchor-both-repo")
132 await _push_branch(db_session, repo_id, "feat/anchor", head_commit_id=_COMMIT_A)
133 await _push_branch(db_session, repo_id, "main", head_commit_id=_COMMIT_B)
134
135 r = await client.post(
136 f"/api/repos/{repo_id}/proposals",
137 json={"title": "Anchor test", "fromBranch": "feat/anchor", "toBranch": "main"},
138 headers=auth_headers,
139 )
140 assert r.status_code == 201
141 body = r.json()
142 assert body["fromCommitId"] == _COMMIT_A
143 assert body["toCommitId"] == _COMMIT_B
144 # Neither fixture commit_id resolves to a real MusehubCommit row here.
145 assert body["fromSnapshotId"] is None
146 assert body["toSnapshotId"] is None
147
148
149 # ---------------------------------------------------------------------------
150 # T1b/T1c — snapshot lookup: hit when the anchor commit is real, miss otherwise
151 # ---------------------------------------------------------------------------
152
153 @pytest.mark.asyncio
154 async def test_snapshot_ids_resolved_from_real_anchor_commits(
155 client: AsyncClient,
156 auth_headers: StrDict,
157 db_session: AsyncSession,
158 ) -> None:
159 repo_id = await _create_repo(client, auth_headers, "anchor-real-commit-repo")
160 await _push_branch_with_real_commit(
161 db_session, repo_id, "feat/real", commit_id=_COMMIT_A, snapshot_id=_SNAPSHOT_A,
162 )
163 await _push_branch_with_real_commit(
164 db_session, repo_id, "main", commit_id=_COMMIT_B, snapshot_id=_SNAPSHOT_B,
165 )
166
167 r = await client.post(
168 f"/api/repos/{repo_id}/proposals",
169 json={"title": "Real anchor test", "fromBranch": "feat/real", "toBranch": "main"},
170 headers=auth_headers,
171 )
172 assert r.status_code == 201
173 body = r.json()
174 assert body["fromCommitId"] == _COMMIT_A
175 assert body["toCommitId"] == _COMMIT_B
176 assert body["fromSnapshotId"] == _SNAPSHOT_A
177 assert body["toSnapshotId"] == _SNAPSHOT_B
178
179
180 # ---------------------------------------------------------------------------
181 # T2 — from_branch has no HEAD → fromCommitId is null
182 # ---------------------------------------------------------------------------
183
184 @pytest.mark.asyncio
185 async def test_from_commit_null_when_from_branch_empty(
186 client: AsyncClient,
187 auth_headers: StrDict,
188 db_session: AsyncSession,
189 ) -> None:
190 repo_id = await _create_repo(client, auth_headers, "anchor-empty-from-repo")
191 await _push_branch(db_session, repo_id, "feat/empty", head_commit_id=None)
192 await _push_branch(db_session, repo_id, "main", head_commit_id=_COMMIT_B)
193
194 r = await client.post(
195 f"/api/repos/{repo_id}/proposals",
196 json={"title": "Empty from", "fromBranch": "feat/empty", "toBranch": "main"},
197 headers=auth_headers,
198 )
199 assert r.status_code == 201
200 body = r.json()
201 assert body["fromCommitId"] is None
202 assert body["fromSnapshotId"] is None
203 assert body["toCommitId"] == _COMMIT_B
204
205
206 # ---------------------------------------------------------------------------
207 # T3 — to_branch has no HEAD → toCommitId is null
208 # ---------------------------------------------------------------------------
209
210 @pytest.mark.asyncio
211 async def test_to_commit_null_when_to_branch_empty(
212 client: AsyncClient,
213 auth_headers: StrDict,
214 db_session: AsyncSession,
215 ) -> None:
216 repo_id = await _create_repo(client, auth_headers, "anchor-empty-to-repo")
217 await _push_branch(db_session, repo_id, "feat/has-commits", head_commit_id=_COMMIT_A)
218 await _push_branch(db_session, repo_id, "main", head_commit_id=None)
219
220 r = await client.post(
221 f"/api/repos/{repo_id}/proposals",
222 json={"title": "Empty to", "fromBranch": "feat/has-commits", "toBranch": "main"},
223 headers=auth_headers,
224 )
225 assert r.status_code == 201
226 body = r.json()
227 assert body["fromCommitId"] == _COMMIT_A
228 assert body["toCommitId"] is None
229 assert body["toSnapshotId"] is None
230
231
232 # ---------------------------------------------------------------------------
233 # T4 — all four fields always present in ProposalResponse (never absent)
234 # ---------------------------------------------------------------------------
235
236 @pytest.mark.asyncio
237 async def test_anchor_fields_always_present_in_response(
238 client: AsyncClient,
239 auth_headers: StrDict,
240 db_session: AsyncSession,
241 ) -> None:
242 repo_id = await _create_repo(client, auth_headers, "anchor-fields-repo")
243 await _push_branch(db_session, repo_id, "feat/fields", head_commit_id=None)
244
245 r = await client.post(
246 f"/api/repos/{repo_id}/proposals",
247 json={"title": "Field presence", "fromBranch": "feat/fields", "toBranch": "main"},
248 headers=auth_headers,
249 )
250 assert r.status_code == 201
251 body = r.json()
252 assert "fromCommitId" in body
253 assert "toCommitId" in body
254 assert "fromSnapshotId" in body
255 assert "toSnapshotId" in body
256
257
258 # ---------------------------------------------------------------------------
259 # T5 — existing proposals (null anchors) don't crash on GET
260 # ---------------------------------------------------------------------------
261
262 @pytest.mark.asyncio
263 async def test_existing_proposal_with_null_anchors_returns_ok(
264 client: AsyncClient,
265 auth_headers: StrDict,
266 db_session: AsyncSession,
267 ) -> None:
268 repo_id = await _create_repo(client, auth_headers, "anchor-legacy-repo")
269 await _push_branch(db_session, repo_id, "feat/legacy")
270
271 # Create via API (will have anchors), then NULL them out to simulate legacy
272 r = await client.post(
273 f"/api/repos/{repo_id}/proposals",
274 json={"title": "Legacy proposal", "fromBranch": "feat/legacy", "toBranch": "main"},
275 headers=auth_headers,
276 )
277 assert r.status_code == 201
278 proposal_id = r.json()["proposalId"]
279
280 row = (await db_session.execute(
281 select(MusehubProposal).where(MusehubProposal.proposal_id == proposal_id)
282 )).scalar_one()
283 row.from_commit_id = None
284 row.to_commit_id = None
285 row.from_snapshot_id = None
286 row.to_snapshot_id = None
287 await db_session.commit()
288
289 get_r = await client.get(
290 f"/api/repos/{repo_id}/proposals/{proposal_id}",
291 headers=auth_headers,
292 )
293 assert get_r.status_code == 200
294 body = get_r.json()
295 assert body["fromCommitId"] is None
296 assert body["toCommitId"] is None
297 assert body["fromSnapshotId"] is None
298 assert body["toSnapshotId"] is None
299
300
301 # ---------------------------------------------------------------------------
302 # T6 — anchors are frozen at creation time, not updated when branch moves
303 # ---------------------------------------------------------------------------
304
305 @pytest.mark.asyncio
306 async def test_commit_anchors_frozen_at_creation_time(
307 client: AsyncClient,
308 auth_headers: StrDict,
309 db_session: AsyncSession,
310 ) -> None:
311 repo_id = await _create_repo(client, auth_headers, "anchor-frozen-repo")
312 await _push_branch(db_session, repo_id, "feat/frozen", head_commit_id=_COMMIT_A)
313 await _push_branch(db_session, repo_id, "main", head_commit_id=_COMMIT_B)
314
315 r = await client.post(
316 f"/api/repos/{repo_id}/proposals",
317 json={"title": "Frozen anchor", "fromBranch": "feat/frozen", "toBranch": "main"},
318 headers=auth_headers,
319 )
320 assert r.status_code == 201
321 proposal_id = r.json()["proposalId"]
322
323 # Advance the branch HEAD after proposal creation
324 _COMMIT_NEW = "sha256:" + "c" * 64
325 branch_row = (await db_session.execute(
326 select(MusehubBranch).where(
327 MusehubBranch.repo_id == repo_id,
328 MusehubBranch.name == "feat/frozen",
329 )
330 )).scalar_one()
331 branch_row.head_commit_id = _COMMIT_NEW
332 await db_session.commit()
333
334 get_r = await client.get(
335 f"/api/repos/{repo_id}/proposals/{proposal_id}",
336 headers=auth_headers,
337 )
338 assert get_r.status_code == 200
339 body = get_r.json()
340 # Anchor must still reflect the HEAD at creation time
341 assert body["fromCommitId"] == _COMMIT_A
File History 3 commits
sha256:7d9bf1946da951ebe5e850065339eb42dcc3f6866956f0cb8b767977d72779fb Merge 'fix/proposal-snapshot-commit-naming' into 'dev' — pr… Human 5 days ago
sha256:6e8ee1704fe89f7c0c3ad95d9c06264b9bd080b89b7582675ab08f1295be3bd6 Merge 'feat/143-proposal-comment-delete' into 'dev' — propo… Human 5 days ago
sha256:3fadb0439bba9451b89229676971c0d4a40900dec7810e9d5f8791b8d950d505 fix: install.sh version from latest published tarball, not … Sonnet 4.6 minor 108 days ago