gabriel / musehub public
test_musehub_ui_proposal_ssr.py python
390 lines 15.2 KB
Raw
sha256:553c8ce398144d0b061f6686995491c66bbcbe7884935ac6c407601026442af1 fix(musehub#198): anchor merged-proposal Files Changed pane… Sonnet 5 patch 2 days ago
1 """SSR tests for MuseHub proposal list + proposal detail pages — issue #569.
2
3 Validates that proposal data is rendered server-side into HTML (not deferred to client
4 JS) and that HTMX fragment requests return bare HTML without the full page shell.
5
6 Covers GET /{owner}/{repo_slug}/proposals:
7 - test_proposal_list_renders_title_server_side — proposal title appears in HTML
8 - test_proposal_list_open_closed_counts_in_tabs — tab counts reflect seeded proposals
9 - test_proposal_list_htmx_fragment_on_tab_switch — HX-Request: true → fragment
10
11 Covers GET /{owner}/{repo_slug}/proposals/{proposal_id}:
12 - test_proposal_detail_renders_title_server_side — proposal title in HTML server-side
13 - test_proposal_detail_renders_diff_stats — branch info in HTML
14 - test_proposal_detail_shows_cli_hint — CLI hint replaces write-capable form
15 - test_proposal_detail_unknown_number_404 — non-existent proposal_id → 404
16 """
17 from __future__ import annotations
18
19 import pytest
20 from httpx import AsyncClient
21 from sqlalchemy.ext.asyncio import AsyncSession
22
23 from muse.core.types import now_utc_iso
24 from musehub.core.genesis import compute_identity_id, compute_proposal_id, compute_repo_id
25 from musehub.db.musehub_repo_models import MusehubRepo
26 from musehub.db.musehub_social_models import MusehubProposal
27
28
29 # ---------------------------------------------------------------------------
30 # Seed helpers
31 # ---------------------------------------------------------------------------
32
33
34 async def _make_repo(
35 db: AsyncSession,
36 owner: str = "proposaldev",
37 slug: str = "proposal-ssr-album",
38 ) -> str:
39 """Seed a public repo and return its repo_id string."""
40 from datetime import datetime, timezone
41 created_at = datetime.now(tz=timezone.utc)
42 owner_id = compute_identity_id(owner.encode())
43 repo_id = compute_repo_id(owner_id, slug, "code", created_at.isoformat())
44 repo = MusehubRepo(
45 repo_id=repo_id,
46 name=slug,
47 owner=owner,
48 slug=slug,
49 visibility="public",
50 owner_user_id=owner_id,
51 created_at=created_at,
52 updated_at=created_at,
53 )
54 db.add(repo)
55 await db.commit()
56 await db.refresh(repo)
57 return str(repo.repo_id)
58
59
60 async def _make_proposal(
61 db: AsyncSession,
62 repo_id: str,
63 *,
64 proposal_number: int = 1,
65 title: str = "Add bossa nova bridge",
66 body: str = "Adds a new bossa nova bridge section.",
67 state: str = "open",
68 from_branch: str = "feat/bossa-nova",
69 to_branch: str = "main",
70 author: str = "beatmaker",
71 ) -> MusehubProposal:
72 """Seed a proposal and return the ORM object."""
73 from datetime import datetime, timezone
74 author_id = compute_identity_id(author.encode())
75 proposal = MusehubProposal(
76 proposal_id=compute_proposal_id(repo_id, author_id, from_branch, to_branch, now_utc_iso()),
77 repo_id=repo_id,
78 proposal_number=proposal_number,
79 title=title,
80 body=body,
81 state=state,
82 from_branch=from_branch,
83 to_branch=to_branch,
84 author=author,
85 )
86 db.add(proposal)
87 await db.commit()
88 await db.refresh(proposal)
89 return proposal
90
91
92 # ---------------------------------------------------------------------------
93 # Proposal list SSR tests
94 # ---------------------------------------------------------------------------
95
96
97 async def test_proposal_list_renders_title_server_side(
98 client: AsyncClient,
99 db_session: AsyncSession,
100 ) -> None:
101 """Proposal title is rendered into the HTML response server-side without client JS."""
102 repo_id = await _make_repo(db_session)
103 await _make_proposal(db_session, repo_id, title="Funk bridge with wah pedal")
104 response = await client.get("/proposaldev/proposal-ssr-album/proposals")
105 assert response.status_code == 200
106 assert "text/html" in response.headers["content-type"]
107 assert "Funk bridge with wah pedal" in response.text
108
109
110 async def test_proposal_list_open_closed_counts_in_tabs(
111 client: AsyncClient,
112 db_session: AsyncSession,
113 ) -> None:
114 """State tabs display SSR-computed open/merged/closed counts."""
115 repo_id = await _make_repo(db_session)
116 await _make_proposal(db_session, repo_id, proposal_number=1, title="Open proposal 1", state="open")
117 await _make_proposal(db_session, repo_id, proposal_number=2, title="Open proposal 2", state="open")
118 await _make_proposal(db_session, repo_id, proposal_number=3, title="Merged proposal", state="merged")
119 response = await client.get("/proposaldev/proposal-ssr-album/proposals")
120 assert response.status_code == 200
121 body = response.text
122 # Tab counts for open and merged must appear as server-rendered numbers.
123 assert "2" in body # open_count
124 assert "1" in body # merged_count
125
126
127 async def test_proposal_list_htmx_fragment_on_tab_switch(
128 client: AsyncClient,
129 db_session: AsyncSession,
130 ) -> None:
131 """HX-Request: true with state=merged returns a bare HTML fragment."""
132 repo_id = await _make_repo(db_session)
133 await _make_proposal(db_session, repo_id, title="Merged feature", state="merged")
134 response = await client.get(
135 "/proposaldev/proposal-ssr-album/proposals?state=merged",
136 headers={"HX-Request": "true"},
137 )
138 assert response.status_code == 200
139 body = response.text
140 # Fragment must NOT contain the full HTML page shell.
141 assert "<html" not in body
142 assert "<head" not in body
143 # Proposal title must appear in the fragment.
144 assert "Merged feature" in body
145
146
147 # ---------------------------------------------------------------------------
148 # Proposal detail SSR tests
149 # ---------------------------------------------------------------------------
150
151
152 async def test_proposal_detail_renders_title_server_side(
153 client: AsyncClient,
154 db_session: AsyncSession,
155 ) -> None:
156 """Proposal title and branch info appear in the detail page HTML server-side."""
157 repo_id = await _make_repo(db_session)
158 proposal = await _make_proposal(
159 db_session, repo_id, title="Add jazz chord voicings", from_branch="feat/jazz"
160 )
161 response = await client.get(f"/proposaldev/proposal-ssr-album/proposals/{proposal.proposal_id}")
162 assert response.status_code == 200
163 assert "text/html" in response.headers["content-type"]
164 assert "Add jazz chord voicings" in response.text
165
166
167 async def test_proposal_detail_renders_diff_stats(
168 client: AsyncClient,
169 db_session: AsyncSession,
170 ) -> None:
171 """Branch names (from_branch / to_branch) appear in the detail page HTML."""
172 repo_id = await _make_repo(db_session)
173 proposal = await _make_proposal(
174 db_session,
175 repo_id,
176 title="Bass groove proposal",
177 from_branch="feat/bass-groove",
178 to_branch="dev",
179 )
180 response = await client.get(f"/proposaldev/proposal-ssr-album/proposals/{proposal.proposal_id}")
181 assert response.status_code == 200
182 body = response.text
183 # Both branch names must appear in the server-rendered HTML.
184 assert "feat/bass-groove" in body
185 assert "dev" in body
186
187
188 async def test_proposal_detail_shows_cli_hint(
189 client: AsyncClient,
190 db_session: AsyncSession,
191 ) -> None:
192 """The proposal detail page shows CLI merge hints instead of write-capable HTMX forms."""
193 repo_id = await _make_repo(db_session)
194 proposal = await _make_proposal(db_session, repo_id, title="CLI-hint proposal", state="open")
195 response = await client.get(f"/proposaldev/proposal-ssr-album/proposals/{proposal.proposal_id}")
196 assert response.status_code == 200
197 body = response.text
198 # MSign is stateless — no write forms; CLI hint must be present instead.
199 assert "hx-post" not in body
200 assert "muse hub proposal" in body.lower()
201
202
203 async def test_proposal_detail_unknown_number_404(
204 client: AsyncClient,
205 db_session: AsyncSession,
206 ) -> None:
207 """A request for a non-existent proposal id returns HTTP 404."""
208 await _make_repo(db_session)
209 response = await client.get(
210 "/proposaldev/proposal-ssr-album/proposals/nonexistent-proposal-id"
211 )
212 assert response.status_code == 404
213
214
215 # ---------------------------------------------------------------------------
216 # musehub#198 — Files Changed panel "X of 0" / all-removed regression
217 #
218 # Root cause: proposal_detail_page resolves from_snapshot_id/to_snapshot_id by
219 # looking up LIVE MusehubBranch rows by name. Once a proposal is merged and its
220 # source branch is deleted (the normal, encouraged post-merge cleanup step),
221 # that lookup returns None, get_snapshot_diff computes total_files=0 from an
222 # empty manifest, and misclassifies the entire base manifest as "removed" —
223 # a perfectly good merge renders as "wipes the whole repo."
224 # ---------------------------------------------------------------------------
225
226
227 async def _make_snapshot(db: AsyncSession, manifest: dict[str, str]) -> str:
228 import msgpack
229 from muse.core.ids import hash_snapshot
230 from musehub.db.musehub_repo_models import MusehubSnapshot
231 from datetime import datetime, timezone
232
233 snap_id = hash_snapshot(manifest, [])
234 existing = await db.get(MusehubSnapshot, snap_id)
235 if existing is None:
236 db.add(MusehubSnapshot(
237 snapshot_id=snap_id,
238 directories=[],
239 entry_count=len(manifest),
240 manifest_blob=msgpack.packb(manifest, use_bin_type=True),
241 created_at=datetime.now(tz=timezone.utc),
242 ))
243 await db.commit()
244 return snap_id
245
246
247 async def _make_commit_on_branch(
248 db: AsyncSession,
249 repo_id: str,
250 *,
251 branch: str,
252 snapshot_id: str,
253 parent_ids: list[str] | None = None,
254 seed: str,
255 ) -> str:
256 from datetime import datetime, timezone
257 from muse.core.types import blob_id
258 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef
259 from musehub.core.genesis import compute_branch_id
260
261 now = datetime.now(tz=timezone.utc)
262 commit_id = blob_id(f"commit-{seed}".encode())
263 db.add(MusehubCommit(
264 commit_id=commit_id,
265 branch=branch,
266 parent_ids=parent_ids or [],
267 message=f"commit {seed}",
268 author="beatmaker",
269 timestamp=now,
270 committed_at_raw=now.isoformat(),
271 snapshot_id=snapshot_id,
272 ))
273 db.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id))
274 branch_row = (await db.execute(
275 __import__("sqlalchemy").select(MusehubBranch).where(
276 MusehubBranch.repo_id == repo_id, MusehubBranch.name == branch,
277 )
278 )).scalar_one_or_none()
279 if branch_row is None:
280 db.add(MusehubBranch(
281 branch_id=compute_branch_id(repo_id, branch),
282 repo_id=repo_id, name=branch, head_commit_id=commit_id,
283 ))
284 else:
285 branch_row.head_commit_id = commit_id
286 await db.commit()
287 return commit_id
288
289
290 class TestFilesChangedPanelAfterMerge:
291 async def test_reproduces_x_of_0_when_source_branch_deleted_post_merge(
292 self, client: AsyncClient, db_session: AsyncSession,
293 ) -> None:
294 """RED: with the deleted from_branch, get_snapshot_diff currently sees
295 an empty new_manifest and misreports the base manifest as fully removed."""
296 from musehub.services.musehub_repository import get_snapshot_diff
297
298 repo_id = await _make_repo(db_session, owner="mergedev", slug="merged-album")
299
300 base_manifest = {"a.mid": "sha256:" + "a" * 64, "b.mid": "sha256:" + "b" * 64}
301 base_snap = await _make_snapshot(db_session, base_manifest)
302 base_commit = await _make_commit_on_branch(
303 db_session, repo_id, branch="dev", snapshot_id=base_snap, seed="base",
304 )
305
306 feature_manifest = dict(base_manifest, **{"c.mid": "sha256:" + "c" * 64})
307 feature_snap = await _make_snapshot(db_session, feature_manifest)
308 await _make_commit_on_branch(
309 db_session, repo_id, branch="feat/new-track", snapshot_id=feature_snap,
310 parent_ids=[base_commit], seed="feat",
311 )
312
313 merge_commit = await _make_commit_on_branch(
314 db_session, repo_id, branch="dev", snapshot_id=feature_snap,
315 parent_ids=[base_commit], seed="merge",
316 )
317
318 # Simulate the standard post-merge cleanup: delete the source branch.
319 from musehub.db.musehub_repo_models import MusehubBranch
320 from sqlalchemy import delete as sa_delete
321 await db_session.execute(
322 sa_delete(MusehubBranch).where(
323 MusehubBranch.repo_id == repo_id, MusehubBranch.name == "feat/new-track",
324 )
325 )
326 await db_session.commit()
327
328 # This is exactly what proposal_detail_page did before the fix: resolve
329 # from_branch by live name lookup. With the branch gone, from_snapshot_id
330 # is None -- reproducing the bug directly against get_snapshot_diff.
331 buggy_diff = await get_snapshot_diff(db_session, repo_id, None, base_snap)
332 assert buggy_diff["total_files"] == 0
333 assert set(buggy_diff["removed"]) == set(base_manifest)
334
335 async def test_files_changed_panel_correct_for_merged_proposal_after_fix(
336 self, client: AsyncClient, db_session: AsyncSession,
337 ) -> None:
338 """GREEN: the rendered page must reflect the merge's real diff (one file
339 added, nothing removed, correct denominator) even after the source
340 branch is deleted -- using merge_commit_id's own lineage instead of a
341 live from_branch lookup."""
342 from musehub.db.musehub_repo_models import MusehubBranch
343 from musehub.db.musehub_social_models import MusehubProposal
344 from muse.core.types import fake_id
345 from sqlalchemy import delete as sa_delete
346
347 repo_id = await _make_repo(db_session, owner="mergedev2", slug="merged-album-2")
348
349 base_manifest = {"a.mid": "sha256:" + "a" * 64, "b.mid": "sha256:" + "b" * 64}
350 base_snap = await _make_snapshot(db_session, base_manifest)
351 base_commit = await _make_commit_on_branch(
352 db_session, repo_id, branch="dev", snapshot_id=base_snap, seed="base2",
353 )
354
355 feature_manifest = dict(base_manifest, **{"c.mid": "sha256:" + "c" * 64})
356 feature_snap = await _make_snapshot(db_session, feature_manifest)
357 await _make_commit_on_branch(
358 db_session, repo_id, branch="feat/new-track-2", snapshot_id=feature_snap,
359 parent_ids=[base_commit], seed="feat2",
360 )
361
362 merge_commit = await _make_commit_on_branch(
363 db_session, repo_id, branch="dev", snapshot_id=feature_snap,
364 parent_ids=[base_commit], seed="merge2",
365 )
366
367 proposal = await _make_proposal(
368 db_session, repo_id,
369 title="Add new track", from_branch="feat/new-track-2", to_branch="dev",
370 state="merged",
371 )
372 proposal.merge_commit_id = merge_commit
373 await db_session.commit()
374
375 await db_session.execute(
376 sa_delete(MusehubBranch).where(
377 MusehubBranch.repo_id == repo_id, MusehubBranch.name == "feat/new-track-2",
378 )
379 )
380 await db_session.commit()
381
382 response = await client.get(
383 f"/mergedev2/merged-album-2/proposals/{proposal.proposal_id}"
384 )
385 assert response.status_code == 200
386 body = response.text
387 # Exactly one file added ("c.mid"), zero removed, denominator reflects
388 # the real 3-file post-merge manifest -- never "0 of 0" / "3 of 0".
389 assert "1 of 3" in body
390 assert "c.mid" in body
File History 1 commit
sha256:553c8ce398144d0b061f6686995491c66bbcbe7884935ac6c407601026442af1 fix(musehub#198): anchor merged-proposal Files Changed pane… Sonnet 5 patch 2 days ago