"""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