"""SSR tests for MuseHub proposal list + proposal detail pages — issue #569. Validates that proposal data is rendered server-side into HTML (not deferred to client JS) and that HTMX fragment requests return bare HTML without the full page shell. Covers GET /{owner}/{repo_slug}/proposals: - test_proposal_list_renders_title_server_side — proposal title appears in HTML - test_proposal_list_open_closed_counts_in_tabs — tab counts reflect seeded proposals - test_proposal_list_htmx_fragment_on_tab_switch — HX-Request: true → fragment Covers GET /{owner}/{repo_slug}/proposals/{proposal_id}: - test_proposal_detail_renders_title_server_side — proposal title in HTML server-side - test_proposal_detail_renders_diff_stats — branch info in HTML - test_proposal_detail_shows_cli_hint — CLI hint replaces write-capable form - test_proposal_detail_unknown_number_404 — non-existent proposal_id → 404 """ from __future__ import annotations import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from muse.core.types import now_utc_iso from musehub.core.genesis import compute_identity_id, compute_proposal_id, compute_repo_id from musehub.db.musehub_repo_models import MusehubRepo from musehub.db.musehub_social_models import MusehubProposal # --------------------------------------------------------------------------- # Seed helpers # --------------------------------------------------------------------------- async def _make_repo( db: AsyncSession, owner: str = "proposaldev", slug: str = "proposal-ssr-album", ) -> str: """Seed a public repo and return its repo_id string.""" from datetime import datetime, timezone created_at = datetime.now(tz=timezone.utc) owner_id = compute_identity_id(owner.encode()) repo_id = compute_repo_id(owner_id, slug, "code", created_at.isoformat()) repo = MusehubRepo( repo_id=repo_id, name=slug, owner=owner, slug=slug, visibility="public", owner_user_id=owner_id, created_at=created_at, updated_at=created_at, ) db.add(repo) await db.commit() await db.refresh(repo) return str(repo.repo_id) async def _make_proposal( db: AsyncSession, repo_id: str, *, proposal_number: int = 1, title: str = "Add bossa nova bridge", body: str = "Adds a new bossa nova bridge section.", state: str = "open", from_branch: str = "feat/bossa-nova", to_branch: str = "main", author: str = "beatmaker", ) -> MusehubProposal: """Seed a proposal and return the ORM object.""" from datetime import datetime, timezone author_id = compute_identity_id(author.encode()) proposal = MusehubProposal( proposal_id=compute_proposal_id(repo_id, author_id, from_branch, to_branch, now_utc_iso()), repo_id=repo_id, proposal_number=proposal_number, title=title, body=body, state=state, from_branch=from_branch, to_branch=to_branch, author=author, ) db.add(proposal) await db.commit() await db.refresh(proposal) return proposal # --------------------------------------------------------------------------- # Proposal list SSR tests # --------------------------------------------------------------------------- async def test_proposal_list_renders_title_server_side( client: AsyncClient, db_session: AsyncSession, ) -> None: """Proposal title is rendered into the HTML response server-side without client JS.""" repo_id = await _make_repo(db_session) await _make_proposal(db_session, repo_id, title="Funk bridge with wah pedal") response = await client.get("/proposaldev/proposal-ssr-album/proposals") assert response.status_code == 200 assert "text/html" in response.headers["content-type"] assert "Funk bridge with wah pedal" in response.text async def test_proposal_list_open_closed_counts_in_tabs( client: AsyncClient, db_session: AsyncSession, ) -> None: """State tabs display SSR-computed open/merged/closed counts.""" repo_id = await _make_repo(db_session) await _make_proposal(db_session, repo_id, proposal_number=1, title="Open proposal 1", state="open") await _make_proposal(db_session, repo_id, proposal_number=2, title="Open proposal 2", state="open") await _make_proposal(db_session, repo_id, proposal_number=3, title="Merged proposal", state="merged") response = await client.get("/proposaldev/proposal-ssr-album/proposals") assert response.status_code == 200 body = response.text # Tab counts for open and merged must appear as server-rendered numbers. assert "2" in body # open_count assert "1" in body # merged_count async def test_proposal_list_htmx_fragment_on_tab_switch( client: AsyncClient, db_session: AsyncSession, ) -> None: """HX-Request: true with state=merged returns a bare HTML fragment.""" repo_id = await _make_repo(db_session) await _make_proposal(db_session, repo_id, title="Merged feature", state="merged") response = await client.get( "/proposaldev/proposal-ssr-album/proposals?state=merged", headers={"HX-Request": "true"}, ) assert response.status_code == 200 body = response.text # Fragment must NOT contain the full HTML page shell. assert " None: """Proposal title and branch info appear in the detail page HTML server-side.""" repo_id = await _make_repo(db_session) proposal = await _make_proposal( db_session, repo_id, title="Add jazz chord voicings", from_branch="feat/jazz" ) response = await client.get(f"/proposaldev/proposal-ssr-album/proposals/{proposal.proposal_id}") assert response.status_code == 200 assert "text/html" in response.headers["content-type"] assert "Add jazz chord voicings" in response.text async def test_proposal_detail_renders_diff_stats( client: AsyncClient, db_session: AsyncSession, ) -> None: """Branch names (from_branch / to_branch) appear in the detail page HTML.""" repo_id = await _make_repo(db_session) proposal = await _make_proposal( db_session, repo_id, title="Bass groove proposal", from_branch="feat/bass-groove", to_branch="dev", ) response = await client.get(f"/proposaldev/proposal-ssr-album/proposals/{proposal.proposal_id}") assert response.status_code == 200 body = response.text # Both branch names must appear in the server-rendered HTML. assert "feat/bass-groove" in body assert "dev" in body async def test_proposal_detail_shows_cli_hint( client: AsyncClient, db_session: AsyncSession, ) -> None: """The proposal detail page shows CLI merge hints instead of write-capable HTMX forms.""" repo_id = await _make_repo(db_session) proposal = await _make_proposal(db_session, repo_id, title="CLI-hint proposal", state="open") response = await client.get(f"/proposaldev/proposal-ssr-album/proposals/{proposal.proposal_id}") assert response.status_code == 200 body = response.text # MSign is stateless — no write forms; CLI hint must be present instead. assert "hx-post" not in body assert "muse hub proposal" in body.lower() async def test_proposal_detail_unknown_number_404( client: AsyncClient, db_session: AsyncSession, ) -> None: """A request for a non-existent proposal id returns HTTP 404.""" await _make_repo(db_session) response = await client.get( "/proposaldev/proposal-ssr-album/proposals/nonexistent-proposal-id" ) assert response.status_code == 404 # --------------------------------------------------------------------------- # musehub#198 — Files Changed panel "X of 0" / all-removed regression # # Root cause: proposal_detail_page resolves from_snapshot_id/to_snapshot_id by # looking up LIVE MusehubBranch rows by name. Once a proposal is merged and its # source branch is deleted (the normal, encouraged post-merge cleanup step), # that lookup returns None, get_snapshot_diff computes total_files=0 from an # empty manifest, and misclassifies the entire base manifest as "removed" — # a perfectly good merge renders as "wipes the whole repo." # --------------------------------------------------------------------------- async def _make_snapshot(db: AsyncSession, manifest: dict[str, str]) -> str: import msgpack from muse.core.ids import hash_snapshot from musehub.db.musehub_repo_models import MusehubSnapshot from datetime import datetime, timezone snap_id = hash_snapshot(manifest, []) existing = await db.get(MusehubSnapshot, snap_id) if existing is None: db.add(MusehubSnapshot( snapshot_id=snap_id, directories=[], entry_count=len(manifest), manifest_blob=msgpack.packb(manifest, use_bin_type=True), created_at=datetime.now(tz=timezone.utc), )) await db.commit() return snap_id async def _make_commit_on_branch( db: AsyncSession, repo_id: str, *, branch: str, snapshot_id: str, parent_ids: list[str] | None = None, seed: str, ) -> str: from datetime import datetime, timezone from muse.core.types import blob_id from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef from musehub.core.genesis import compute_branch_id now = datetime.now(tz=timezone.utc) commit_id = blob_id(f"commit-{seed}".encode()) db.add(MusehubCommit( commit_id=commit_id, branch=branch, parent_ids=parent_ids or [], message=f"commit {seed}", author="beatmaker", timestamp=now, committed_at_raw=now.isoformat(), snapshot_id=snapshot_id, )) db.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id)) branch_row = (await db.execute( __import__("sqlalchemy").select(MusehubBranch).where( MusehubBranch.repo_id == repo_id, MusehubBranch.name == branch, ) )).scalar_one_or_none() if branch_row is None: db.add(MusehubBranch( branch_id=compute_branch_id(repo_id, branch), repo_id=repo_id, name=branch, head_commit_id=commit_id, )) else: branch_row.head_commit_id = commit_id await db.commit() return commit_id class TestFilesChangedPanelAfterMerge: async def test_reproduces_x_of_0_when_source_branch_deleted_post_merge( self, client: AsyncClient, db_session: AsyncSession, ) -> None: """RED: with the deleted from_branch, get_snapshot_diff currently sees an empty new_manifest and misreports the base manifest as fully removed.""" from musehub.services.musehub_repository import get_snapshot_diff repo_id = await _make_repo(db_session, owner="mergedev", slug="merged-album") base_manifest = {"a.mid": "sha256:" + "a" * 64, "b.mid": "sha256:" + "b" * 64} base_snap = await _make_snapshot(db_session, base_manifest) base_commit = await _make_commit_on_branch( db_session, repo_id, branch="dev", snapshot_id=base_snap, seed="base", ) feature_manifest = dict(base_manifest, **{"c.mid": "sha256:" + "c" * 64}) feature_snap = await _make_snapshot(db_session, feature_manifest) await _make_commit_on_branch( db_session, repo_id, branch="feat/new-track", snapshot_id=feature_snap, parent_ids=[base_commit], seed="feat", ) merge_commit = await _make_commit_on_branch( db_session, repo_id, branch="dev", snapshot_id=feature_snap, parent_ids=[base_commit], seed="merge", ) # Simulate the standard post-merge cleanup: delete the source branch. from musehub.db.musehub_repo_models import MusehubBranch from sqlalchemy import delete as sa_delete await db_session.execute( sa_delete(MusehubBranch).where( MusehubBranch.repo_id == repo_id, MusehubBranch.name == "feat/new-track", ) ) await db_session.commit() # This is exactly what proposal_detail_page did before the fix: resolve # from_branch by live name lookup. With the branch gone, from_snapshot_id # is None -- reproducing the bug directly against get_snapshot_diff. buggy_diff = await get_snapshot_diff(db_session, repo_id, None, base_snap) assert buggy_diff["total_files"] == 0 assert set(buggy_diff["removed"]) == set(base_manifest) async def test_files_changed_panel_correct_for_merged_proposal_after_fix( self, client: AsyncClient, db_session: AsyncSession, ) -> None: """GREEN: the rendered page must reflect the merge's real diff (one file added, nothing removed, correct denominator) even after the source branch is deleted -- using merge_commit_id's own lineage instead of a live from_branch lookup.""" from musehub.db.musehub_repo_models import MusehubBranch from musehub.db.musehub_social_models import MusehubProposal from muse.core.types import fake_id from sqlalchemy import delete as sa_delete repo_id = await _make_repo(db_session, owner="mergedev2", slug="merged-album-2") base_manifest = {"a.mid": "sha256:" + "a" * 64, "b.mid": "sha256:" + "b" * 64} base_snap = await _make_snapshot(db_session, base_manifest) base_commit = await _make_commit_on_branch( db_session, repo_id, branch="dev", snapshot_id=base_snap, seed="base2", ) feature_manifest = dict(base_manifest, **{"c.mid": "sha256:" + "c" * 64}) feature_snap = await _make_snapshot(db_session, feature_manifest) await _make_commit_on_branch( db_session, repo_id, branch="feat/new-track-2", snapshot_id=feature_snap, parent_ids=[base_commit], seed="feat2", ) merge_commit = await _make_commit_on_branch( db_session, repo_id, branch="dev", snapshot_id=feature_snap, parent_ids=[base_commit], seed="merge2", ) proposal = await _make_proposal( db_session, repo_id, title="Add new track", from_branch="feat/new-track-2", to_branch="dev", state="merged", ) proposal.merge_commit_id = merge_commit await db_session.commit() await db_session.execute( sa_delete(MusehubBranch).where( MusehubBranch.repo_id == repo_id, MusehubBranch.name == "feat/new-track-2", ) ) await db_session.commit() response = await client.get( f"/mergedev2/merged-album-2/proposals/{proposal.proposal_id}" ) assert response.status_code == 200 body = response.text # Exactly one file added ("c.mid"), zero removed, denominator reflects # the real 3-file post-merge manifest -- never "0 of 0" / "3 of 0". assert "1 of 3" in body assert "c.mid" in body