test_musehub_ui_credits_activity_ssr.py
python
| 1 | """SSR tests for the MuseHub credits and activity pages (issue #574). |
| 2 | |
| 3 | Verifies that ``GET /{owner}/{repo_slug}/credits`` and |
| 4 | ``GET /{owner}/{repo_slug}/activity`` render data server-side |
| 5 | rather than relying on client-side JavaScript fetches. |
| 6 | |
| 7 | Tests: |
| 8 | - test_credits_page_renders_contributor_name_server_side |
| 9 | — Seed a commit, GET credits page, assert author name in HTML |
| 10 | - test_credits_page_shows_total_contributors |
| 11 | — Total contributor count is present in SSR HTML |
| 12 | - test_activity_page_renders_event_server_side |
| 13 | — Seed an event, GET activity page, assert event description in HTML |
| 14 | - test_activity_page_filter_form_has_hx_get |
| 15 | — Filter form has hx-get attribute for HTMX partial updates |
| 16 | - test_activity_page_htmx_fragment_path |
| 17 | — HX-Request: true returns fragment only (no <html>) |
| 18 | - test_activity_page_event_type_filter |
| 19 | — ?event_type=commit_pushed returns only matching events |
| 20 | """ |
| 21 | from __future__ import annotations |
| 22 | |
| 23 | import uuid |
| 24 | from datetime import datetime, timezone |
| 25 | |
| 26 | import pytest |
| 27 | from httpx import AsyncClient |
| 28 | from sqlalchemy.ext.asyncio import AsyncSession |
| 29 | |
| 30 | from musehub.db.musehub_models import MusehubCommit, MusehubEvent, MusehubRepo |
| 31 | |
| 32 | _OWNER = "muse-artist" |
| 33 | _SLUG = "debut-album" |
| 34 | _USER_ID = "550e8400-e29b-41d4-a716-446655440000" # matches test_user fixture |
| 35 | |
| 36 | |
| 37 | # --------------------------------------------------------------------------- |
| 38 | # Seed helpers |
| 39 | # --------------------------------------------------------------------------- |
| 40 | |
| 41 | |
| 42 | async def _make_repo(db: AsyncSession) -> str: |
| 43 | """Seed a repo and return its repo_id string.""" |
| 44 | repo = MusehubRepo( |
| 45 | name=_SLUG, |
| 46 | owner=_OWNER, |
| 47 | slug=_SLUG, |
| 48 | visibility="public", |
| 49 | owner_user_id=_USER_ID, |
| 50 | ) |
| 51 | db.add(repo) |
| 52 | await db.commit() |
| 53 | await db.refresh(repo) |
| 54 | return str(repo.repo_id) |
| 55 | |
| 56 | |
| 57 | async def _make_commit( |
| 58 | db: AsyncSession, |
| 59 | repo_id: str, |
| 60 | *, |
| 61 | author: str = "alice", |
| 62 | message: str = "Add melody", |
| 63 | ) -> MusehubCommit: |
| 64 | """Seed a commit and return the ORM object.""" |
| 65 | commit = MusehubCommit( |
| 66 | commit_id=str(uuid.uuid4())[:16], |
| 67 | repo_id=repo_id, |
| 68 | branch="main", |
| 69 | parent_ids=[], |
| 70 | message=message, |
| 71 | author=author, |
| 72 | timestamp=datetime.now(tz=timezone.utc), |
| 73 | ) |
| 74 | db.add(commit) |
| 75 | await db.commit() |
| 76 | await db.refresh(commit) |
| 77 | return commit |
| 78 | |
| 79 | |
| 80 | async def _make_event( |
| 81 | db: AsyncSession, |
| 82 | repo_id: str, |
| 83 | *, |
| 84 | event_type: str = "commit_pushed", |
| 85 | actor: str = "alice", |
| 86 | description: str = "Pushed a commit", |
| 87 | ) -> MusehubEvent: |
| 88 | """Seed an activity event and return the ORM object.""" |
| 89 | event = MusehubEvent( |
| 90 | event_id=str(uuid.uuid4()), |
| 91 | repo_id=repo_id, |
| 92 | event_type=event_type, |
| 93 | actor=actor, |
| 94 | description=description, |
| 95 | event_metadata={}, |
| 96 | created_at=datetime.now(tz=timezone.utc), |
| 97 | ) |
| 98 | db.add(event) |
| 99 | await db.commit() |
| 100 | await db.refresh(event) |
| 101 | return event |
| 102 | |
| 103 | |
| 104 | # --------------------------------------------------------------------------- |
| 105 | # Credits page SSR tests |
| 106 | # --------------------------------------------------------------------------- |
| 107 | |
| 108 | |
| 109 | @pytest.mark.anyio |
| 110 | async def test_credits_page_renders_contributor_name_server_side( |
| 111 | client: AsyncClient, |
| 112 | db_session: AsyncSession, |
| 113 | test_user: object, |
| 114 | ) -> None: |
| 115 | """Author name appears in the credits HTML response without a JS round-trip. |
| 116 | |
| 117 | The handler aggregates contributor credits from commit history server-side |
| 118 | and inlines them into the Jinja2 template. |
| 119 | """ |
| 120 | repo_id = await _make_repo(db_session) |
| 121 | await _make_commit(db_session, repo_id, author="charlie-contributor") |
| 122 | |
| 123 | resp = await client.get(f"/{_OWNER}/{_SLUG}/credits") |
| 124 | assert resp.status_code == 200 |
| 125 | assert "text/html" in resp.headers["content-type"] |
| 126 | assert "charlie-contributor" in resp.text |
| 127 | |
| 128 | |
| 129 | @pytest.mark.anyio |
| 130 | async def test_credits_page_shows_total_contributors( |
| 131 | client: AsyncClient, |
| 132 | db_session: AsyncSession, |
| 133 | test_user: object, |
| 134 | ) -> None: |
| 135 | """Total contributor count is rendered server-side in the credits HTML.""" |
| 136 | repo_id = await _make_repo(db_session) |
| 137 | await _make_commit(db_session, repo_id, author="alice") |
| 138 | await _make_commit(db_session, repo_id, author="bob") |
| 139 | |
| 140 | resp = await client.get(f"/{_OWNER}/{_SLUG}/credits") |
| 141 | assert resp.status_code == 200 |
| 142 | # The template renders e.g. "2 contributors" |
| 143 | assert "2 contributor" in resp.text |
| 144 | |
| 145 | |
| 146 | # --------------------------------------------------------------------------- |
| 147 | # Activity page SSR tests |
| 148 | # --------------------------------------------------------------------------- |
| 149 | |
| 150 | |
| 151 | @pytest.mark.anyio |
| 152 | async def test_activity_page_renders_event_server_side( |
| 153 | client: AsyncClient, |
| 154 | db_session: AsyncSession, |
| 155 | test_user: object, |
| 156 | ) -> None: |
| 157 | """Event description appears in the activity HTML response without a JS round-trip. |
| 158 | |
| 159 | The handler fetches events from the DB server-side and inlines them |
| 160 | into the Jinja2 template. |
| 161 | """ |
| 162 | repo_id = await _make_repo(db_session) |
| 163 | await _make_event( |
| 164 | db_session, repo_id, |
| 165 | event_type="commit_pushed", |
| 166 | actor="dana", |
| 167 | description="Pushed feat/synth-bass", |
| 168 | ) |
| 169 | |
| 170 | resp = await client.get(f"/{_OWNER}/{_SLUG}/activity") |
| 171 | assert resp.status_code == 200 |
| 172 | assert "text/html" in resp.headers["content-type"] |
| 173 | assert "Pushed feat/synth-bass" in resp.text |
| 174 | |
| 175 | |
| 176 | @pytest.mark.anyio |
| 177 | async def test_activity_page_filter_form_has_hx_get( |
| 178 | client: AsyncClient, |
| 179 | db_session: AsyncSession, |
| 180 | test_user: object, |
| 181 | ) -> None: |
| 182 | """The event-type filter form has hx-get for HTMX partial updates.""" |
| 183 | await _make_repo(db_session) |
| 184 | |
| 185 | resp = await client.get(f"/{_OWNER}/{_SLUG}/activity") |
| 186 | assert resp.status_code == 200 |
| 187 | assert "hx-get" in resp.text |
| 188 | |
| 189 | |
| 190 | @pytest.mark.anyio |
| 191 | async def test_activity_page_htmx_fragment_path( |
| 192 | client: AsyncClient, |
| 193 | db_session: AsyncSession, |
| 194 | test_user: object, |
| 195 | ) -> None: |
| 196 | """GET with HX-Request: true returns the rows fragment, not the full page. |
| 197 | |
| 198 | When HTMX issues a partial swap request it sends HX-Request: true. The |
| 199 | response must NOT contain full-page chrome and MUST contain the event rows. |
| 200 | """ |
| 201 | repo_id = await _make_repo(db_session) |
| 202 | await _make_event( |
| 203 | db_session, repo_id, |
| 204 | event_type="pr_opened", |
| 205 | actor="eve", |
| 206 | description="Opened PR: add chorus", |
| 207 | ) |
| 208 | |
| 209 | resp = await client.get( |
| 210 | f"/{_OWNER}/{_SLUG}/activity", |
| 211 | headers={"HX-Request": "true"}, |
| 212 | ) |
| 213 | assert resp.status_code == 200 |
| 214 | assert "text/html" in resp.headers["content-type"] |
| 215 | assert "<html" not in resp.text |
| 216 | assert "<!DOCTYPE html>" not in resp.text |
| 217 | assert "Opened PR: add chorus" in resp.text |
| 218 | |
| 219 | |
| 220 | @pytest.mark.anyio |
| 221 | async def test_activity_page_event_type_filter( |
| 222 | client: AsyncClient, |
| 223 | db_session: AsyncSession, |
| 224 | test_user: object, |
| 225 | ) -> None: |
| 226 | """?event_type=commit_pushed renders only commit_pushed events in the HTML.""" |
| 227 | repo_id = await _make_repo(db_session) |
| 228 | await _make_event( |
| 229 | db_session, repo_id, |
| 230 | event_type="commit_pushed", |
| 231 | actor="frank", |
| 232 | description="Pushed commit: add bassline", |
| 233 | ) |
| 234 | await _make_event( |
| 235 | db_session, repo_id, |
| 236 | event_type="pr_merged", |
| 237 | actor="grace", |
| 238 | description="Merged PR: horn section", |
| 239 | ) |
| 240 | |
| 241 | resp = await client.get( |
| 242 | f"/{_OWNER}/{_SLUG}/activity", |
| 243 | params={"event_type": "commit_pushed"}, |
| 244 | ) |
| 245 | assert resp.status_code == 200 |
| 246 | assert "Pushed commit: add bassline" in resp.text |
| 247 | assert "Merged PR: horn section" not in resp.text |