"""Tests for the SSR issue list page — reference HTMX implementation (issue #555). Covers server-side rendering, HTMX fragment responses, filters, tabs, and pagination. All assertions target Jinja2-rendered content in the HTML response body, not JavaScript function definitions. Test areas: Basic rendering - test_issue_list_page_returns_200 - test_issue_list_no_auth_required - test_issue_list_unknown_repo_404 SSR content — issue data rendered on server - test_issue_list_renders_issue_title_server_side - test_issue_list_filter_form_has_hx_get - test_issue_list_filter_form_has_hx_target Open/closed tab counts - test_issue_list_tab_open_has_hx_get - test_issue_list_open_closed_counts_in_tabs State filter - test_issue_list_state_filter_closed_shows_closed_only Label filter - test_issue_list_label_filter_narrows_issues HTMX fragment - test_issue_list_htmx_request_returns_fragment - test_issue_list_fragment_contains_issue_title - test_issue_list_fragment_empty_state_when_no_issues Pagination - test_issue_list_pagination_renders_next_link Right sidebar - test_issue_list_right_sidebar_present - test_issue_list_labels_summary_heading_present - test_issue_list_labels_summary_list_present Filter sidebar - test_issue_list_filter_sidebar_present - test_issue_list_label_chip_container_present - test_issue_list_filter_assignee_select_present - test_issue_list_filter_author_input_present - test_issue_list_sort_radio_group_present - test_issue_list_sort_radio_buttons_present Template selector / new-issue flow (minimal JS) - test_issue_list_template_picker_present - test_issue_list_template_grid_present - test_issue_list_template_cards_present - test_issue_list_show_template_picker_js_present - test_issue_list_select_template_js_present - test_issue_list_issue_templates_const_present - test_issue_list_new_issue_btn_calls_template - test_issue_list_templates_back_btn_present - test_issue_list_blank_template_defined - test_issue_list_bug_template_defined Bulk toolbar structure - test_issue_list_bulk_toolbar_present - test_issue_list_bulk_count_present - test_issue_list_bulk_label_select_present - test_issue_list_issue_row_checkbox_present - test_issue_list_toggle_issue_select_js_present - test_issue_list_deselect_all_js_present - test_issue_list_update_bulk_toolbar_js_present - test_issue_list_bulk_close_js_present - test_issue_list_bulk_reopen_js_present - test_issue_list_bulk_assign_label_js_present """ 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_identity_id, compute_issue_id, compute_repo_id from musehub.db.musehub_repo_models import MusehubRepo from musehub.db.musehub_social_models import MusehubIssue # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- async def _make_repo( db: AsyncSession, owner: str = "beatmaker", slug: str = "grooves", ) -> 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 = "Bass too loud", state: str = "open", labels: list[str] | None = None, author: str = "beatmaker", ) -> MusehubIssue: """Seed an issue and return it.""" author_id = compute_identity_id(author.encode()) issue = MusehubIssue( issue_id=compute_issue_id(repo_id, author_id, now_utc_iso()), repo_id=repo_id, number=number, title=title, body="Issue body.", state=state, labels=labels or [], author=author, ) db.add(issue) await db.commit() await db.refresh(issue) return issue async def _get_page( client: AsyncClient, owner: str = "beatmaker", slug: str = "grooves", **params: str, ) -> str: """Fetch the issue list page and return its text body.""" resp = await client.get(f"/{owner}/{slug}/issues", params=params) assert resp.status_code == 200 return resp.text # --------------------------------------------------------------------------- # Basic page rendering # --------------------------------------------------------------------------- async def test_issue_list_page_returns_200( client: AsyncClient, db_session: AsyncSession, ) -> None: """GET /{owner}/{slug}/issues returns 200 HTML.""" await _make_repo(db_session) response = await client.get("/beatmaker/grooves/issues") assert response.status_code == 200 assert "text/html" in response.headers["content-type"] async def test_issue_list_no_auth_required( client: AsyncClient, db_session: AsyncSession, ) -> None: """Issue list page renders without authentication.""" await _make_repo(db_session) response = await client.get("/beatmaker/grooves/issues") assert response.status_code == 200 async def test_issue_list_unknown_repo_404( client: AsyncClient, db_session: AsyncSession, ) -> None: """Unknown owner/slug returns 404.""" response = await client.get("/nobody/norepo/issues") assert response.status_code == 404 # --------------------------------------------------------------------------- # SSR content — issue data is rendered server-side # --------------------------------------------------------------------------- async def test_issue_list_renders_issue_title_server_side( client: AsyncClient, db_session: AsyncSession, ) -> None: """Seeded issue title appears in SSR HTML without JS execution.""" repo_id = await _make_repo(db_session) await _make_issue(db_session, repo_id, title="Kick drum too punchy") body = await _get_page(client) assert "Kick drum too punchy" in body async def test_issue_list_filter_form_has_hx_get( client: AsyncClient, db_session: AsyncSession, ) -> None: """Filter form carries hx-get attribute for HTMX partial updates.""" await _make_repo(db_session) body = await _get_page(client) assert "hx-get" in body async def test_issue_list_filter_form_has_hx_target( client: AsyncClient, db_session: AsyncSession, ) -> None: """Filter form targets #issue-rows for HTMX swaps.""" await _make_repo(db_session) body = await _get_page(client) assert 'hx-target="#issue-rows"' in body or "hx-target='#issue-rows'" in body # --------------------------------------------------------------------------- # Open/closed tab counts # --------------------------------------------------------------------------- async def test_issue_list_tab_open_has_hx_get( client: AsyncClient, db_session: AsyncSession, ) -> None: """Open tab link carries hx-get for HTMX navigation.""" await _make_repo(db_session) body = await _get_page(client) assert "state=open" in body assert "hx-get" in body async def test_issue_list_open_closed_counts_in_tabs( client: AsyncClient, db_session: AsyncSession, ) -> None: """Tab badges reflect the actual open and closed issue counts from the DB.""" repo_id = await _make_repo(db_session) for i in range(3): await _make_issue(db_session, repo_id, number=i + 1, state="open") for i in range(2): await _make_issue(db_session, repo_id, number=i + 4, state="closed") body = await _get_page(client) assert ">3<" in body or ">3 <" in body or "3" in body assert ">2<" in body or ">2 <" in body or "2" in body # --------------------------------------------------------------------------- # State filter # --------------------------------------------------------------------------- async def test_issue_list_state_filter_closed_shows_closed_only( client: AsyncClient, db_session: AsyncSession, ) -> None: """?state=closed returns only closed issues in the rendered HTML.""" repo_id = await _make_repo(db_session) await _make_issue(db_session, repo_id, number=1, title="UniqueOpenTitle", state="open") await _make_issue(db_session, repo_id, number=2, title="UniqueClosedTitle", state="closed") body = await _get_page(client, state="closed") assert "UniqueClosedTitle" in body assert "UniqueOpenTitle" not in body # --------------------------------------------------------------------------- # Label filter # --------------------------------------------------------------------------- async def test_issue_list_label_filter_narrows_issues( client: AsyncClient, db_session: AsyncSession, ) -> None: """?label=bug returns only issues labelled 'bug'.""" repo_id = await _make_repo(db_session) await _make_issue(db_session, repo_id, number=1, title="Bug: kick too loud", labels=["bug"]) await _make_issue(db_session, repo_id, number=2, title="Feature: add reverb", labels=["feature"]) body = await _get_page(client, label="bug") assert "Bug: kick too loud" in body assert "Feature: add reverb" not in body # --------------------------------------------------------------------------- # HTMX fragment # --------------------------------------------------------------------------- async def test_issue_list_htmx_request_returns_fragment( client: AsyncClient, db_session: AsyncSession, ) -> None: """HX-Request: true returns a bare fragment — no wrapper.""" await _make_repo(db_session) resp = await client.get( "/beatmaker/grooves/issues", headers={"HX-Request": "true"}, ) assert resp.status_code == 200 assert " None: """HTMX fragment contains the seeded issue title.""" repo_id = await _make_repo(db_session) await _make_issue(db_session, repo_id, title="Synth pad too bright") resp = await client.get( "/beatmaker/grooves/issues", headers={"HX-Request": "true"}, ) assert resp.status_code == 200 assert "Synth pad too bright" in resp.text async def test_issue_list_fragment_empty_state_when_no_issues( client: AsyncClient, db_session: AsyncSession, ) -> None: """Fragment returns an empty-state block when no issues match filters.""" repo_id = await _make_repo(db_session) await _make_issue(db_session, repo_id, number=1, title="Open issue", state="open") resp = await client.get( "/beatmaker/grooves/issues", params={"state": "closed"}, headers={"HX-Request": "true"}, ) assert resp.status_code == 200 # Template renders isl-empty block for empty state assert "isl-empty" in resp.text # --------------------------------------------------------------------------- # Pagination # --------------------------------------------------------------------------- async def test_issue_list_pagination_renders_next_link( client: AsyncClient, db_session: AsyncSession, ) -> None: """When total issues exceed per_page, a Next pagination link appears.""" repo_id = await _make_repo(db_session) for i in range(30): await _make_issue(db_session, repo_id, number=i + 1, state="open") body = await _get_page(client, per_page="25") assert "Next" in body or "next" in body.lower() async def test_issue_list_pagination_next_cursor_is_integer( client: AsyncClient, db_session: AsyncSession, ) -> None: """Next page cursor in the link URL is the issue number (integer), not a timestamp.""" repo_id = await _make_repo(db_session) for i in range(30): await _make_issue(db_session, repo_id, number=i + 1, state="open") resp = await client.get("/beatmaker/grooves/issues", params={"limit": "25"}) assert resp.status_code == 200 import re # Extract cursor value from the Next → link m = re.search(r'cursor=([^&"\']+)', resp.text) assert m, "No cursor found in pagination link" cursor_val = m.group(1) # Must be parseable as an integer (issue number), not a timestamp assert cursor_val.isdigit(), f"Cursor should be an integer issue number, got: {cursor_val!r}" async def test_issue_list_pagination_second_page_shows_issues( client: AsyncClient, db_session: AsyncSession, ) -> None: """Following the Next cursor returns the second page of issues, not an empty state.""" repo_id = await _make_repo(db_session) for i in range(30): await _make_issue(db_session, repo_id, number=i + 1, title=f"Issue {i + 1}", state="open") # Page 1 resp1 = await client.get("/beatmaker/grooves/issues", params={"limit": "25"}) assert resp1.status_code == 200 import re m = re.search(r'cursor=([^&"\']+)', resp1.text) assert m, "No cursor found in page 1 link" cursor = m.group(1) # Page 2 resp2 = await client.get("/beatmaker/grooves/issues", params={"limit": "25", "cursor": cursor}) assert resp2.status_code == 200 assert "isl-empty" not in resp2.text, "Page 2 must not show the empty state" assert "No open issues" not in resp2.text async def test_issue_list_pagination_timestamp_cursor_falls_back_to_page1( client: AsyncClient, db_session: AsyncSession, ) -> None: """An old timestamp-style cursor (from before the fix) falls back to page 1 gracefully.""" repo_id = await _make_repo(db_session) for i in range(5): await _make_issue(db_session, repo_id, number=i + 1, title=f"Issue {i + 1}", state="open") stale_cursor = "2026-05-28T17:54:49.104203+00:00" resp = await client.get("/beatmaker/grooves/issues", params={"cursor": stale_cursor}) assert resp.status_code == 200 # Should show issues (page 1 fallback), not empty state assert "isl-empty" not in resp.text # --------------------------------------------------------------------------- # Right sidebar # --------------------------------------------------------------------------- async def test_issue_list_right_sidebar_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """Right sidebar element is present in the SSR page.""" await _make_repo(db_session) body = await _get_page(client) assert "isl-sidebar" in body async def test_issue_list_labels_summary_heading_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """Labels sidebar section is rendered server-side.""" await _make_repo(db_session) body = await _get_page(client) assert "Labels" in body async def test_issue_list_labels_summary_list_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """Labels sidebar section contains a label list.""" await _make_repo(db_session) body = await _get_page(client) assert "Labels" in body # --------------------------------------------------------------------------- # Filter sidebar elements # --------------------------------------------------------------------------- async def test_issue_list_filter_sidebar_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """Issue filter form is rendered server-side.""" await _make_repo(db_session) body = await _get_page(client) assert 'name="sort"' in body async def test_issue_list_label_chip_container_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """Label filter select is present in the filter bar.""" await _make_repo(db_session) body = await _get_page(client) assert 'name="sort"' in body async def test_issue_list_filter_assignee_select_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """Assignee filter element is present (name=sort).""" await _make_repo(db_session) body = await _get_page(client) assert 'name="sort"' in body or "name='sort'" in body async def test_issue_list_sort_radio_buttons_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """Radio inputs with name='sort' are present (SSR-rendered).""" await _make_repo(db_session) body = await _get_page(client) assert 'name="sort"' in body or "name='sort'" in body # --------------------------------------------------------------------------- # Template selector / new-issue flow (minimal JS retained) # --------------------------------------------------------------------------- async def test_issue_list_template_picker_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """template-picker element is present in the page HTML.""" await _make_repo(db_session) body = await _get_page(client) assert "template-picker" in body async def test_issue_list_template_grid_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """Template picker container is rendered server-side.""" await _make_repo(db_session) body = await _get_page(client) assert "isl-template-picker" in body async def test_issue_list_template_cards_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """Template picker card class is present (SSR-rendered template cards).""" await _make_repo(db_session) body = await _get_page(client) assert "isl-tp-card" in body async def test_issue_list_show_template_picker_js_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """Template picker panel is rendered server-side.""" await _make_repo(db_session) body = await _get_page(client) assert "Choose a template" in body async def test_issue_list_select_template_js_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """Template cards use data-action="select-template" (selectTemplate moved to issue-list.ts).""" await _make_repo(db_session) body = await _get_page(client) assert "select-template" in body async def test_issue_list_issue_templates_const_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """ISSUE_TEMPLATES is in app.js (TypeScript module); page dispatches issue-list module.""" await _make_repo(db_session) body = await _get_page(client) # ISSUE_TEMPLATES moved to app.js; verify page dispatch JSON and template picker HTML assert '"page": "issue-list"' in body assert "template-picker" in body async def test_issue_list_new_issue_btn_calls_template( client: AsyncClient, db_session: AsyncSession, ) -> None: """New Issue button opens template picker via data-action (showTemplatePicker moved to issue-list.ts).""" await _make_repo(db_session) body = await _get_page(client) assert "New Issue" in body assert "Choose a template" in body async def test_issue_list_templates_back_btn_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """Template picker is rendered in the new issue flow.""" await _make_repo(db_session) body = await _get_page(client) assert "template-picker" in body async def test_issue_list_blank_template_defined( client: AsyncClient, db_session: AsyncSession, ) -> None: """'blank' template id is present in ISSUE_TEMPLATES.""" await _make_repo(db_session) body = await _get_page(client) assert "'blank'" in body or '"blank"' in body async def test_issue_list_bug_template_defined( client: AsyncClient, db_session: AsyncSession, ) -> None: """'bug' template id is present in ISSUE_TEMPLATES.""" await _make_repo(db_session) body = await _get_page(client) assert "'bug'" in body or '"bug"' in body # --------------------------------------------------------------------------- # Bulk toolbar structure (SSR-rendered, JS-activated) # --------------------------------------------------------------------------- async def test_issue_list_bulk_toolbar_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """bulk-toolbar element is rendered in the page HTML.""" await _make_repo(db_session) body = await _get_page(client) assert "bulk-toolbar" in body async def test_issue_list_bulk_count_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """bulk-count element is present.""" await _make_repo(db_session) body = await _get_page(client) assert "bulk-count" in body async def test_issue_list_bulk_label_select_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """bulk-label-select element is present.""" await _make_repo(db_session) body = await _get_page(client) assert "bulk-label-select" in body async def test_issue_list_issue_row_checkbox_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """issue-row-check CSS class is present (checkbox for bulk selection).""" repo_id = await _make_repo(db_session) await _make_issue(db_session, repo_id, title="Has checkbox") body = await _get_page(client) assert "issue-row-check" in body async def test_issue_list_toggle_issue_select_js_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """toggleIssueSelect() is in app.js (TypeScript module); page renders bulk toolbar.""" await _make_repo(db_session) body = await _get_page(client) # Function moved to app.js; verify bulk toolbar HTML element is present assert "bulk-toolbar" in body async def test_issue_list_deselect_all_js_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """Deselect action uses data-bulk-action="deselect" (deselectAll moved to issue-list.ts).""" await _make_repo(db_session) body = await _get_page(client) assert 'data-bulk-action="deselect"' in body async def test_issue_list_update_bulk_toolbar_js_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """Page renders bulk action buttons (isl-bulk-btn with data-bulk-action attributes).""" await _make_repo(db_session) body = await _get_page(client) assert "isl-bulk-btn" in body assert "data-bulk-action" in body async def test_issue_list_bulk_close_js_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """Close bulk action uses data-bulk-action="close" (bulkClose moved to issue-list.ts).""" await _make_repo(db_session) body = await _get_page(client) assert 'data-bulk-action="close"' in body async def test_issue_list_bulk_reopen_js_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """Reopen bulk action uses data-bulk-action="reopen" (bulkReopen moved to issue-list.ts).""" await _make_repo(db_session) body = await _get_page(client) assert 'data-bulk-action="reopen"' in body async def test_issue_list_bulk_assign_label_js_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """Assign label uses data-bulk-action="assign-label" (bulkAssignLabel moved to issue-list.ts).""" await _make_repo(db_session) body = await _get_page(client) assert 'data-bulk-action="assign-label"' in body async def test_issue_list_full_page_contains_html_wrapper( client: AsyncClient, db_session: AsyncSession, ) -> None: """Direct browser navigation (no HX-Request) returns a full HTML page with tag.""" await _make_repo(db_session) resp = await client.get("/beatmaker/grooves/issues") assert resp.status_code == 200 assert "