"""Tests for the SSR issue detail page — HTMX SSR + comment threading (issue #568). Covers server-side rendering of issue body, comment thread, HTMX fragment responses, status action buttons, sidebar, and 404 handling. Test areas: Basic rendering - test_issue_detail_renders_title_server_side - test_issue_detail_unknown_number_404 SSR body content - test_issue_detail_renders_body_markdown - test_issue_detail_empty_body_shows_placeholder Comments - test_issue_detail_renders_comments_server_side - test_issue_detail_no_comments_shows_placeholder HTMX attributes - test_issue_detail_comment_form_has_hx_post - test_issue_detail_close_button_has_hx_post - test_issue_detail_reopen_button_has_hx_post HTMX fragment - test_issue_detail_htmx_request_returns_comment_fragment """ from __future__ import annotations import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from datetime import datetime, timezone from muse.core.types import now_utc_iso from musehub.core.genesis import compute_comment_id, compute_identity_id, compute_issue_id, compute_release_id, compute_repo_id from musehub.db.musehub_release_models import MusehubRelease from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef, MusehubRepo from musehub.db.musehub_social_models import MusehubIssue, MusehubIssueComment from musehub.types.json_types import StrDict # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- async def _make_repo( db: AsyncSession, owner: str = "songwriter", slug: str = "melodies", ) -> str: """Seed a public repo and return its repo_id string.""" owner_id = compute_identity_id(owner.encode()) created_at = datetime.now(tz=timezone.utc) repo = MusehubRepo( repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()), 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_issue( db: AsyncSession, repo_id: str, *, number: int = 1, title: str = "Verse needs a bridge", body: str = "The verse feels incomplete.", state: str = "open", author: str = "songwriter", labels: list[str] | None = None, symbol_anchors: list[str] | None = None, ) -> MusehubIssue: """Seed an issue and return it.""" author_id = compute_identity_id(author.encode()) now = datetime.now(tz=timezone.utc) issue = MusehubIssue( issue_id=compute_issue_id(repo_id, author_id, now.isoformat()), repo_id=repo_id, number=number, title=title, body=body, state=state, labels=labels or [], symbol_anchors=symbol_anchors or [], author=author, ) db.add(issue) await db.commit() await db.refresh(issue) return issue async def _make_comment( db: AsyncSession, issue_id: str, repo_id: str, *, author: str = "producer", body: str = "Good point.", parent_id: str | None = None, ) -> MusehubIssueComment: """Seed a comment and return it.""" author_id = compute_identity_id(author.encode()) comment = MusehubIssueComment( comment_id=compute_comment_id(issue_id, author_id, now_utc_iso()), issue_id=issue_id, repo_id=repo_id, author=author, body=body, parent_id=parent_id, ) db.add(comment) await db.commit() await db.refresh(comment) return comment async def _get_detail( client: AsyncClient, number: int = 1, owner: str = "songwriter", slug: str = "melodies", headers: StrDict | None = None, ) -> tuple[int, str]: """Fetch the issue detail page; return (status_code, body_text).""" resp = await client.get( f"/{owner}/{slug}/issues/{number}", headers=headers or {}, ) return resp.status_code, resp.text # --------------------------------------------------------------------------- # Basic rendering # --------------------------------------------------------------------------- async def test_issue_detail_renders_title_server_side( client: AsyncClient, db_session: AsyncSession, ) -> None: """Issue title appears in the HTML rendered on the server.""" repo_id = await _make_repo(db_session) await _make_issue(db_session, repo_id, title="Chorus hook is off-key") status, body = await _get_detail(client) assert status == 200 assert "Chorus hook is off-key" in body async def test_issue_detail_unknown_number_404( client: AsyncClient, db_session: AsyncSession, ) -> None: """A non-existent issue number returns 404.""" await _make_repo(db_session) resp = await client.get("/songwriter/melodies/issues/999") assert resp.status_code == 404 # --------------------------------------------------------------------------- # SSR body content # --------------------------------------------------------------------------- async def test_issue_detail_renders_body_markdown( client: AsyncClient, db_session: AsyncSession, ) -> None: """Issue body with Markdown bold is rendered as in the HTML.""" repo_id = await _make_repo(db_session) await _make_issue(db_session, repo_id, body="The **bass line** needs work.") status, body = await _get_detail(client) assert status == 200 assert "bass line" in body async def test_issue_detail_empty_body_shows_placeholder( client: AsyncClient, db_session: AsyncSession, ) -> None: """An issue with empty body renders the 'No description provided' placeholder.""" repo_id = await _make_repo(db_session) await _make_issue(db_session, repo_id, body="") status, body = await _get_detail(client) assert status == 200 assert "No description provided" in body # --------------------------------------------------------------------------- # Comments # --------------------------------------------------------------------------- async def test_issue_detail_renders_comments_server_side( client: AsyncClient, db_session: AsyncSession, ) -> None: """A seeded comment body appears in the rendered HTML.""" repo_id = await _make_repo(db_session) issue = await _make_issue(db_session, repo_id) await _make_comment(db_session, issue.issue_id, repo_id, body="Agreed, bridge it up!") status, body = await _get_detail(client) assert status == 200 assert "Agreed, bridge it up!" in body async def test_issue_detail_no_comments_shows_placeholder( client: AsyncClient, db_session: AsyncSession, ) -> None: """When there are no comments the placeholder text is rendered.""" repo_id = await _make_repo(db_session) await _make_issue(db_session, repo_id) status, body = await _get_detail(client) assert status == 200 assert "No activity yet" in body # --------------------------------------------------------------------------- # HTMX attributes # --------------------------------------------------------------------------- async def test_issue_detail_cli_card_shown( client: AsyncClient, db_session: AsyncSession, ) -> None: """The 'Act via CLI' card is rendered with a muse hub issue snippet.""" repo_id = await _make_repo(db_session) await _make_issue(db_session, repo_id) status, body = await _get_detail(client) assert status == 200 assert "muse hub issue" in body async def test_issue_detail_open_state_shows_open_badge( client: AsyncClient, db_session: AsyncSession, ) -> None: """An open issue renders an Open state badge and filed-by attribution.""" repo_id = await _make_repo(db_session) await _make_issue(db_session, repo_id, state="open") status, body = await _get_detail(client) assert status == 200 assert "Open" in body assert "filed by" in body async def test_issue_detail_closed_state_shows_closed_badge( client: AsyncClient, db_session: AsyncSession, ) -> None: """A closed issue renders a Closed state badge.""" repo_id = await _make_repo(db_session) await _make_issue(db_session, repo_id, state="closed") status, body = await _get_detail(client) assert status == 200 assert "Closed" in body # --------------------------------------------------------------------------- # HTMX fragment # --------------------------------------------------------------------------- async def test_issue_detail_htmx_request_returns_comment_fragment( client: AsyncClient, db_session: AsyncSession, ) -> None: """GET with HX-Request: true returns the comment fragment (no full page shell).""" repo_id = await _make_repo(db_session) issue = await _make_issue(db_session, repo_id) await _make_comment(db_session, issue.issue_id, repo_id, body="Fragment comment here.") status, body = await _get_detail(client, headers={"HX-Request": "true"}) assert status == 200 assert "Fragment comment here." in body # Fragment must not include the full page chrome assert " None: """Issues with symbol_anchors set display the Symbol Anchors panel.""" repo_id = await _make_repo(db_session) await _make_issue( db_session, repo_id, labels=["bug"], symbol_anchors=["muse/core/snapshot.py::compute_snapshot_id"], ) status, body = await _get_detail(client) assert status == 200 assert "Symbol Anchors" in body assert "compute_snapshot_id" in body assert "muse/core/snapshot.py" in body async def test_symbol_labels_excluded_from_display_labels( client: AsyncClient, db_session: AsyncSession, ) -> None: """symbol_anchors appear in the anchors panel, not in the regular label list.""" repo_id = await _make_repo(db_session) await _make_issue( db_session, repo_id, labels=["performance"], symbol_anchors=["muse/core/snapshot.py::compute_snapshot_id"], ) status, body = await _get_detail(client) assert status == 200 # The 'performance' issue type appears in the page as its display name assert "Performance" in body # The raw symbol anchor address does NOT appear as a label chip (only in the anchors panel) assert "symbol:muse/core/snapshot.py::compute_snapshot_id" not in body async def test_symbol_anchors_panel_absent_without_symbol_labels( client: AsyncClient, db_session: AsyncSession, ) -> None: """Issues with no symbol: labels do not show the Symbol Anchors panel.""" repo_id = await _make_repo(db_session) await _make_issue(db_session, repo_id, labels=["bug", "performance"]) status, body = await _get_detail(client) assert status == 200 assert "Symbol Anchors" not in body async def test_act_panel_shown( client: AsyncClient, db_session: AsyncSession, ) -> None: """The CLI/MCP/REST act panel is always present on the issue detail page.""" repo_id = await _make_repo(db_session) await _make_issue(db_session, repo_id) status, body = await _get_detail(client) assert status == 200 assert "muse hub issue comment" in body assert "create_issue" in body assert "/issues/" in body # --------------------------------------------------------------------------- # Release card — Muse-native VCS graph release tracking # --------------------------------------------------------------------------- _COMMIT_ID_A = "a" * 64 _COMMIT_ID_B = "b" * 64 async def _make_commit( db: AsyncSession, repo_id: str, commit_id: str, *, message: str = "fix: resolve the issue", author: str = "gabriel", branch: str = "dev", timestamp: datetime | None = None, ) -> MusehubCommit: commit = MusehubCommit( commit_id=commit_id, branch=branch, parent_ids=[], message=message, author=author, timestamp=timestamp or datetime(2026, 4, 1, 12, 0, 0, tzinfo=timezone.utc), ) db.add(commit) db.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id)) await db.commit() await db.refresh(commit) return commit async def _make_release( db: AsyncSession, repo_id: str, tag: str, commit_id: str, *, semver_major: int = 0, semver_minor: int = 2, semver_patch: int = 1, ) -> MusehubRelease: rel = MusehubRelease( release_id=compute_release_id(repo_id, tag, now_utc_iso()), repo_id=repo_id, tag=tag, title=f"Release {tag}", commit_id=commit_id, semver_major=semver_major, semver_minor=semver_minor, semver_patch=semver_patch, channel="stable", ) db.add(rel) await db.commit() await db.refresh(rel) return rel async def test_release_card_no_commits_shows_placeholder( client: AsyncClient, db_session: AsyncSession, ) -> None: """Issue with no commit_anchors shows 'no commits linked' in the release card.""" repo_id = await _make_repo(db_session) await _make_issue(db_session, repo_id) status, body = await _get_detail(client) assert status == 200 assert "Release" in body assert "no commits linked" in body async def test_release_card_shows_commit_hash_when_anchored( client: AsyncClient, db_session: AsyncSession, ) -> None: """Issue with a commit_anchor shows the short hash in the release card.""" repo_id = await _make_repo(db_session) commit = await _make_commit(db_session, repo_id, _COMMIT_ID_A, message="fix: buffer overflow") issue = await _make_issue(db_session, repo_id) issue.commit_anchors = [commit.commit_id] await db_session.commit() status, body = await _get_detail(client) assert status == 200 # Short hash (first 8 chars) visible in the release card assert _COMMIT_ID_A[:8] in body # Commit message rendered assert "fix: buffer overflow" in body async def test_release_card_shows_landed_tag_when_in_release( client: AsyncClient, db_session: AsyncSession, ) -> None: """When an anchor commit is in a tagged release, that release tag is shown.""" repo_id = await _make_repo(db_session) # Anchor commit at T=1; release commit at T=2 (after) → anchor is contained. anchor = await _make_commit( db_session, repo_id, _COMMIT_ID_A, timestamp=datetime(2026, 3, 1, tzinfo=timezone.utc), ) rel_commit = await _make_commit( db_session, repo_id, _COMMIT_ID_B, timestamp=datetime(2026, 4, 1, tzinfo=timezone.utc), ) await _make_release(db_session, repo_id, "v0.2.1", rel_commit.commit_id) issue = await _make_issue(db_session, repo_id) issue.commit_anchors = [anchor.commit_id] await db_session.commit() status, body = await _get_detail(client) assert status == 200 assert "v0.2.1" in body async def test_release_card_shows_next_tag_when_pending( client: AsyncClient, db_session: AsyncSession, ) -> None: """When commits exist but no release contains them, the next proposed tag is shown.""" repo_id = await _make_repo(db_session) # Anchor commit is AFTER the release commit → not yet contained. rel_commit = await _make_commit( db_session, repo_id, _COMMIT_ID_B, timestamp=datetime(2026, 3, 1, tzinfo=timezone.utc), ) await _make_release( db_session, repo_id, "v0.2.1", rel_commit.commit_id, semver_major=0, semver_minor=2, semver_patch=1, ) anchor = await _make_commit( db_session, repo_id, _COMMIT_ID_A, timestamp=datetime(2026, 4, 1, tzinfo=timezone.utc), ) issue = await _make_issue(db_session, repo_id) issue.commit_anchors = [anchor.commit_id] await db_session.commit() status, body = await _get_detail(client) assert status == 200 # Proposed next patch release tag visible assert "v0.2.2" in body assert "proposed" in body # --------------------------------------------------------------------------- # Intelligence panel — singularity mode (Phase 2B) # --------------------------------------------------------------------------- async def test_intel_panel_shows_symbol_header( client: AsyncClient, db_session: AsyncSession, ) -> None: """Symbol anchors extracted from labels appear in the Symbol Anchors panel.""" repo_id = await _make_repo(db_session) await _make_issue( db_session, repo_id, labels=["symbol:muse/core/snapshot.py::compute_snapshot_id"], ) status, body = await _get_detail(client) assert status == 200 # Symbol name rendered in the Symbol Anchors panel (not the Intelligence panel, # which only appears when intel data is indexed for the repo). assert "compute_snapshot_id" in body async def test_intel_panel_not_yet_indexed_hint_shown( client: AsyncClient, db_session: AsyncSession, ) -> None: """When no symbol index exists, the symbol anchor still appears in the anchors panel.""" repo_id = await _make_repo(db_session) await _make_issue( db_session, repo_id, labels=["symbol:muse/core/snapshot.py::build_manifest"], ) status, body = await _get_detail(client) assert status == 200 # The symbol address is shown in the Symbol Anchors panel; the Intelligence # panel is hidden when no index exists (no placeholder text is shown). assert "build_manifest" in body async def test_intel_panel_shows_blast_radius_section( client: AsyncClient, db_session: AsyncSession, ) -> None: """The Intelligence panel renders the Blast radius section.""" repo_id = await _make_repo(db_session) await _make_issue( db_session, repo_id, labels=["symbol:muse/core/snapshot.py::compute_snapshot_id"], ) status, body = await _get_detail(client) assert status == 200 assert "Blast radius" in body async def test_intel_panel_shows_open_issues_section( client: AsyncClient, db_session: AsyncSession, ) -> None: """Symbol Anchors panel renders the anchor address for each anchored symbol.""" repo_id = await _make_repo(db_session) await _make_issue( db_session, repo_id, labels=["symbol:muse/core/snapshot.py::compute_snapshot_id"], ) status, body = await _get_detail(client) assert status == 200 # The anchors panel lists the symbol path — Open issues section was # removed from the Intelligence panel in the flattening refactor. assert "snapshot.py" in body async def test_intel_panel_open_issues_lists_current_issue_number( client: AsyncClient, db_session: AsyncSession, ) -> None: """The Open issues section includes the current issue's own number as #N.""" repo_id = await _make_repo(db_session) await _make_issue( db_session, repo_id, number=7, labels=["symbol:muse/core/snapshot.py::compute_snapshot_id"], state="open", ) status, body = await _get_detail(client, number=7) assert status == 200 assert "#7" in body async def test_intel_placeholder_shown_when_no_intel( client: AsyncClient, db_session: AsyncSession, ) -> None: """Page renders correctly when symbol anchors exist but no intel index is built.""" repo_id = await _make_repo(db_session) await _make_issue( db_session, repo_id, labels=["symbol:muse/core/snapshot.py::compute_snapshot_id"], ) status, body = await _get_detail(client) assert status == 200 # Symbol anchor appears in the Symbol Anchors panel; the Intelligence panel # is hidden when no index exists (the flattened design omits the placeholder). assert "compute_snapshot_id" in body