gabriel / musehub public
test_musehub_ui_sessions_ssr.py python
246 lines 7.6 KB
c0f0b481 release: merge dev → main (#5) 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
137 assert "badge-active" in body
138
139
140 @pytest.mark.anyio
141 async def test_sessions_list_htmx_fragment_path(
142 client: AsyncClient,
143 auth_headers: dict[str, str],
144 db_session: AsyncSession,
145 test_user: object,
146 ) -> None:
147 """GET with HX-Request: true returns rows fragment, not the full page.
148
149 When HTMX issues a partial swap request the response must NOT contain
150 the full page chrome and MUST contain the session row markup.
151 """
152 repo_id = await _make_repo(db_session)
153 row = await _make_session(db_session, repo_id)
154 htmx_headers = {**auth_headers, "HX-Request": "true"}
155 resp = await client.get(
156 f"/{_OWNER}/{_SLUG}/sessions", headers=htmx_headers
157 )
158 assert resp.status_code == 200
159 body = resp.text
160 assert row.session_id[:8] in body
161 assert "<!DOCTYPE html>" not in body
162 assert "<html" not in body
163
164
165 @pytest.mark.anyio
166 async def test_sessions_list_empty_state_when_no_sessions(
167 client: AsyncClient,
168 auth_headers: dict[str, str],
169 db_session: AsyncSession,
170 test_user: object,
171 ) -> None:
172 """Empty session list renders an empty-state component server-side (no JS fetch needed)."""
173 await _make_repo(db_session)
174 resp = await client.get(
175 f"/{_OWNER}/{_SLUG}/sessions", headers=auth_headers
176 )
177 assert resp.status_code == 200
178 body = resp.text
179 assert '<div class="session-row' not in body
180 assert "empty-state" in body or "No sessions yet" in body
181
182
183 # ---------------------------------------------------------------------------
184 # Session detail SSR tests
185 # ---------------------------------------------------------------------------
186
187
188 @pytest.mark.anyio
189 async def test_session_detail_renders_session_id(
190 client: AsyncClient,
191 auth_headers: dict[str, str],
192 db_session: AsyncSession,
193 test_user: object,
194 ) -> None:
195 """Session detail page renders the session ID and metadata server-side."""
196 repo_id = await _make_repo(db_session)
197 row = await _make_session(
198 db_session, repo_id, intent="Lay down the horn section", location="Studio B"
199 )
200 resp = await client.get(
201 f"/{_OWNER}/{_SLUG}/sessions/{row.session_id}",
202 headers=auth_headers,
203 )
204 assert resp.status_code == 200
205 body = resp.text
206 assert row.session_id[:8] in body
207 assert "Studio B" in body
208
209
210 @pytest.mark.anyio
211 async def test_session_detail_renders_participants(
212 client: AsyncClient,
213 auth_headers: dict[str, str],
214 db_session: AsyncSession,
215 test_user: object,
216 ) -> None:
217 """Participant user IDs appear in the session detail HTML response."""
218 repo_id = await _make_repo(db_session)
219 row = await _make_session(
220 db_session, repo_id, participants=["alice", "bob"]
221 )
222 resp = await client.get(
223 f"/{_OWNER}/{_SLUG}/sessions/{row.session_id}",
224 headers=auth_headers,
225 )
226 assert resp.status_code == 200
227 body = resp.text
228 assert "alice" in body
229 assert "bob" in body
230
231
232 @pytest.mark.anyio
233 async def test_session_detail_unknown_id_404(
234 client: AsyncClient,
235 auth_headers: dict[str, str],
236 db_session: AsyncSession,
237 test_user: object,
238 ) -> None:
239 """Non-existent session_id returns HTTP 404."""
240 await _make_repo(db_session)
241 fake_id = str(uuid.uuid4())
242 resp = await client.get(
243 f"/{_OWNER}/{_SLUG}/sessions/{fake_id}",
244 headers=auth_headers,
245 )
246 assert resp.status_code == 404