gabriel / musehub public
test_proposal_snapshot_anchors.py python
341 lines 12.6 KB
Raw
sha256:8a1389ae62d0a688d5e027763249bd8faedfc39ab00857d65da6620d1ab8a689 Merge 'feat/9a-4-f7-overseer-provenance' into 'dev' — propo… Human 14 hours 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 15 commits
sha256:8a1389ae62d0a688d5e027763249bd8faedfc39ab00857d65da6620d1ab8a689 Merge 'feat/9a-4-f7-overseer-provenance' into 'dev' — propo… Human 14 hours ago
sha256:bee12c5cbde2334f98421c6c209d768fa6b8004d6705c9ea798ce6c1651bc11f Merge 'infra/database-phase3-4-cleanup' into 'dev' — propos… Human 1 day ago
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 64 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454 chore: bump version to 0.2.0.dev2 — nightly.2, matching muse Sonnet 4.6 patch 67 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 70 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 72 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 85 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 89 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 90 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 91 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 91 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 91 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 91 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 103 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 105 days ago