gabriel / musehub public
test_musehub_ui_sessions_ssr.py python
245 lines 7.5 KB
04faf0e3 feat: supercharge all repo pages, enforce separation of concerns Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """SSR tests for the MuseHub sessions list and session detail pages (issue #573).
2
3 Verifies that both ``GET /{owner}/{repo_slug}/sessions`` and
4 ``GET /{owner}/{repo_slug}/sessions/{session_id}`` render session
5 data server-side rather than relying on client-side JavaScript fetches.
6
7 Tests:
8 - test_sessions_list_renders_session_name_server_side
9 — Seed a session, GET page, assert session_id present in HTML without JS
10 - test_sessions_list_active_badge_present
11 — Active session → badge with "live" in HTML
12 - test_sessions_list_htmx_fragment_path
13 — GET with HX-Request: true → fragment only (no <html>)
14 - test_sessions_list_empty_state_when_no_sessions
15 — No sessions → empty state rendered server-side
16 - test_session_detail_renders_session_id
17 — GET detail page, assert session metadata in HTML
18 - test_session_detail_renders_participants
19 — Seed participant, assert user_id in HTML
20 - test_session_detail_unknown_id_404
21 — Non-existent session_id → 404
22 """
23 from __future__ import annotations
24
25 import uuid
26 from datetime import datetime, timezone
27
28 import pytest
29 from httpx import AsyncClient
30 from sqlalchemy.ext.asyncio import AsyncSession
31
32 from musehub.db.musehub_models import MusehubRepo, MusehubSession
33
34 _OWNER = "composer"
35 _SLUG = "symphony-no-9"
36 _USER_ID = "550e8400-e29b-41d4-a716-446655440000" # matches test_user fixture
37
38
39 # ---------------------------------------------------------------------------
40 # Seed helpers
41 # ---------------------------------------------------------------------------
42
43
44 async def _make_repo(db: AsyncSession) -> str:
45 """Seed a repo and return its repo_id string."""
46 repo = MusehubRepo(
47 name=_SLUG,
48 owner=_OWNER,
49 slug=_SLUG,
50 visibility="public",
51 owner_user_id=_USER_ID,
52 )
53 db.add(repo)
54 await db.commit()
55 await db.refresh(repo)
56 return str(repo.repo_id)
57
58
59 async def _make_session(
60 db: AsyncSession,
61 repo_id: str,
62 *,
63 is_active: bool = False,
64 participants: list[str] | None = None,
65 intent: str = "Record the final movement",
66 location: str = "Studio A",
67 notes: str = "",
68 commits: list[str] | None = None,
69 ) -> MusehubSession:
70 """Seed a recording session and return the ORM row."""
71 session_id = str(uuid.uuid4())
72 started_at = datetime.now(timezone.utc)
73 ended_at = None if is_active else started_at
74 row = MusehubSession(
75 session_id=session_id,
76 repo_id=repo_id,
77 started_at=started_at,
78 ended_at=ended_at,
79 participants=participants or [],
80 intent=intent,
81 location=location,
82 notes=notes,
83 commits=commits or [],
84 is_active=is_active,
85 )
86 db.add(row)
87 await db.commit()
88 await db.refresh(row)
89 return row
90
91
92 # ---------------------------------------------------------------------------
93 # Sessions list SSR tests
94 # ---------------------------------------------------------------------------
95
96
97 @pytest.mark.anyio
98 async def test_sessions_list_renders_session_name_server_side(
99 client: AsyncClient,
100 auth_headers: dict[str, str],
101 db_session: AsyncSession,
102 test_user: object,
103 ) -> None:
104 """Session ID appears in the HTML response without a JS round-trip.
105
106 The handler queries the DB during the request and inlines the session
107 identifier into the Jinja2 template so browsers receive a complete page
108 on first load.
109 """
110 repo_id = await _make_repo(db_session)
111 row = await _make_session(db_session, repo_id, intent="Compose bridge section")
112 resp = await client.get(
113 f"/{_OWNER}/{_SLUG}/sessions", headers=auth_headers
114 )
115 assert resp.status_code == 200
116 body = resp.text
117 assert row.session_id[:8] in body
118 assert "session-row" in body
119
120
121 @pytest.mark.anyio
122 async def test_sessions_list_active_badge_present(
123 client: AsyncClient,
124 auth_headers: dict[str, str],
125 db_session: AsyncSession,
126 test_user: object,
127 ) -> None:
128 """Active session renders a live badge in the server-rendered HTML."""
129 repo_id = await _make_repo(db_session)
130 await _make_session(db_session, repo_id, is_active=True)
131 resp = await client.get(
132 f"/{_OWNER}/{_SLUG}/sessions", headers=auth_headers
133 )
134 assert resp.status_code == 200
135 body = resp.text
136 assert "live" in body.lower() or "Live" in body
137
138
139 @pytest.mark.anyio
140 async def test_sessions_list_htmx_fragment_path(
141 client: AsyncClient,
142 auth_headers: dict[str, str],
143 db_session: AsyncSession,
144 test_user: object,
145 ) -> None:
146 """GET with HX-Request: true returns rows fragment, not the full page.
147
148 When HTMX issues a partial swap request the response must NOT contain
149 the full page chrome and MUST contain the session row markup.
150 """
151 repo_id = await _make_repo(db_session)
152 row = await _make_session(db_session, repo_id)
153 htmx_headers = {**auth_headers, "HX-Request": "true"}
154 resp = await client.get(
155 f"/{_OWNER}/{_SLUG}/sessions", headers=htmx_headers
156 )
157 assert resp.status_code == 200
158 body = resp.text
159 assert row.session_id[:8] in body
160 assert "<!DOCTYPE html>" not in body
161 assert "<html" not in body
162
163
164 @pytest.mark.anyio
165 async def test_sessions_list_empty_state_when_no_sessions(
166 client: AsyncClient,
167 auth_headers: dict[str, str],
168 db_session: AsyncSession,
169 test_user: object,
170 ) -> None:
171 """Empty session list renders an empty-state component server-side (no JS fetch needed)."""
172 await _make_repo(db_session)
173 resp = await client.get(
174 f"/{_OWNER}/{_SLUG}/sessions", headers=auth_headers
175 )
176 assert resp.status_code == 200
177 body = resp.text
178 assert '<div class="session-row' not in body
179 assert "empty-state" in body or "No sessions yet" in body
180
181
182 # ---------------------------------------------------------------------------
183 # Session detail SSR tests
184 # ---------------------------------------------------------------------------
185
186
187 @pytest.mark.anyio
188 async def test_session_detail_renders_session_id(
189 client: AsyncClient,
190 auth_headers: dict[str, str],
191 db_session: AsyncSession,
192 test_user: object,
193 ) -> None:
194 """Session detail page renders the session ID and metadata server-side."""
195 repo_id = await _make_repo(db_session)
196 row = await _make_session(
197 db_session, repo_id, intent="Lay down the horn section", location="Studio B"
198 )
199 resp = await client.get(
200 f"/{_OWNER}/{_SLUG}/sessions/{row.session_id}",
201 headers=auth_headers,
202 )
203 assert resp.status_code == 200
204 body = resp.text
205 assert row.session_id[:8] in body
206 assert "Studio B" in body
207
208
209 @pytest.mark.anyio
210 async def test_session_detail_renders_participants(
211 client: AsyncClient,
212 auth_headers: dict[str, str],
213 db_session: AsyncSession,
214 test_user: object,
215 ) -> None:
216 """Participant user IDs appear in the session detail HTML response."""
217 repo_id = await _make_repo(db_session)
218 row = await _make_session(
219 db_session, repo_id, participants=["alice", "bob"]
220 )
221 resp = await client.get(
222 f"/{_OWNER}/{_SLUG}/sessions/{row.session_id}",
223 headers=auth_headers,
224 )
225 assert resp.status_code == 200
226 body = resp.text
227 assert "alice" in body
228 assert "bob" in body
229
230
231 @pytest.mark.anyio
232 async def test_session_detail_unknown_id_404(
233 client: AsyncClient,
234 auth_headers: dict[str, str],
235 db_session: AsyncSession,
236 test_user: object,
237 ) -> None:
238 """Non-existent session_id returns HTTP 404."""
239 await _make_repo(db_session)
240 fake_id = str(uuid.uuid4())
241 resp = await client.get(
242 f"/{_OWNER}/{_SLUG}/sessions/{fake_id}",
243 headers=auth_headers,
244 )
245 assert resp.status_code == 404