gabriel / musehub public
test_musehub_ui_stash_ssr.py python
213 lines 6.6 KB
c0f0b481 release: merge dev → main (#5) Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """SSR tests for the MuseHub stash page (issue #556).
2
3 Verifies that ``GET /{owner}/{repo_slug}/stash`` renders stash
4 data server-side rather than relying on client-side JavaScript fetches.
5
6 Tests:
7 - test_stash_page_renders_stash_entry_server_side
8 — Seed a stash entry, GET the page, assert branch name in HTML without JS
9 - test_stash_page_shows_total_count
10 — Total count badge present in server-rendered HTML
11 - test_stash_page_apply_form_uses_post
12 — Apply button is a <form method="post">
13 - test_stash_page_drop_has_hx_confirm
14 — Drop button form has hx-confirm attribute (HTMX-native confirmation)
15 - test_stash_page_htmx_request_returns_fragment
16 — HX-Request: true → no <html> in response, but stash data is present
17 - test_stash_page_empty_state_when_no_stashes
18 — Empty stash list → empty state rendered without JS fetch
19 """
20 from __future__ import annotations
21
22 import pytest
23 from httpx import AsyncClient
24 from sqlalchemy.ext.asyncio import AsyncSession
25
26 from musehub.db.musehub_models import MusehubRepo
27 from musehub.db.musehub_stash_models import MusehubStash, MusehubStashEntry
28
29 _OWNER = "bandleader"
30 _SLUG = "concert-setlist"
31 _USER_ID = "550e8400-e29b-41d4-a716-446655440000" # matches test_user fixture
32
33
34 # ---------------------------------------------------------------------------
35 # Seed helpers
36 # ---------------------------------------------------------------------------
37
38
39 async def _make_repo(db: AsyncSession) -> str:
40 """Seed a repo and return its repo_id string."""
41 repo = MusehubRepo(
42 name=_SLUG,
43 owner=_OWNER,
44 slug=_SLUG,
45 visibility="public",
46 owner_user_id=_USER_ID,
47 )
48 db.add(repo)
49 await db.commit()
50 await db.refresh(repo)
51 return str(repo.repo_id)
52
53
54 async def _make_stash(
55 db: AsyncSession,
56 repo_id: str,
57 *,
58 branch: str = "main",
59 message: str | None = "WIP: horn section",
60 num_entries: int = 1,
61 ) -> MusehubStash:
62 """Seed a stash entry with file entries and return it."""
63 stash = MusehubStash(
64 repo_id=repo_id,
65 user_id=_USER_ID,
66 branch=branch,
67 message=message,
68 )
69 db.add(stash)
70 await db.flush()
71
72 for i in range(num_entries):
73 db.add(
74 MusehubStashEntry(
75 stash_id=stash.id,
76 path=f"tracks/track_{i}.mid",
77 object_id=f"sha256:{'b' * 64}",
78 position=i,
79 )
80 )
81
82 await db.commit()
83 await db.refresh(stash)
84 return stash
85
86
87 # ---------------------------------------------------------------------------
88 # SSR tests
89 # ---------------------------------------------------------------------------
90
91
92 @pytest.mark.anyio
93 async def test_stash_page_renders_stash_entry_server_side(
94 client: AsyncClient,
95 auth_headers: dict[str, str],
96 db_session: AsyncSession,
97 test_user: object,
98 ) -> None:
99 """Branch name appears in the HTML response without a JS round-trip.
100
101 The handler queries the DB during the request and inlines the branch
102 name into the Jinja2 template so browsers receive a complete page
103 on the first load.
104 """
105 repo_id = await _make_repo(db_session)
106 await _make_stash(db_session, repo_id, branch="feat/ssr-stash")
107 resp = await client.get(
108 f"/{_OWNER}/{_SLUG}/stash", headers=auth_headers
109 )
110 assert resp.status_code == 200
111 body = resp.text
112 assert "feat/ssr-stash" in body
113 assert "stash-row" in body
114
115
116 @pytest.mark.anyio
117 async def test_stash_page_shows_total_count(
118 client: AsyncClient,
119 auth_headers: dict[str, str],
120 db_session: AsyncSession,
121 test_user: object,
122 ) -> None:
123 """Total stash entry count badge is present in the server-rendered HTML."""
124 repo_id = await _make_repo(db_session)
125 await _make_stash(db_session, repo_id)
126 await _make_stash(db_session, repo_id, branch="feat/second")
127 resp = await client.get(
128 f"/{_OWNER}/{_SLUG}/stash", headers=auth_headers
129 )
130 assert resp.status_code == 200
131 body = resp.text
132 assert "2 stash entries" in body or "stash-count" in body
133
134
135 @pytest.mark.anyio
136 async def test_stash_page_apply_form_uses_post(
137 client: AsyncClient,
138 auth_headers: dict[str, str],
139 db_session: AsyncSession,
140 test_user: object,
141 ) -> None:
142 """Apply action is rendered as a <form method="post"> for HTMX hx-boost compatibility."""
143 repo_id = await _make_repo(db_session)
144 await _make_stash(db_session, repo_id)
145 resp = await client.get(
146 f"/{_OWNER}/{_SLUG}/stash", headers=auth_headers
147 )
148 assert resp.status_code == 200
149 body = resp.text
150 assert 'method="post"' in body or "method='post'" in body
151 assert "/apply" in body
152
153
154 @pytest.mark.anyio
155 async def test_stash_page_drop_has_hx_confirm(
156 client: AsyncClient,
157 auth_headers: dict[str, str],
158 db_session: AsyncSession,
159 test_user: object,
160 ) -> None:
161 """Drop form has hx-confirm attribute for HTMX-native confirmation before destructive action."""
162 repo_id = await _make_repo(db_session)
163 await _make_stash(db_session, repo_id)
164 resp = await client.get(
165 f"/{_OWNER}/{_SLUG}/stash", headers=auth_headers
166 )
167 assert resp.status_code == 200
168 body = resp.text
169 assert "hx-confirm" in body
170 assert "/drop" in body
171
172
173 @pytest.mark.anyio
174 async def test_stash_page_htmx_request_returns_fragment(
175 client: AsyncClient,
176 auth_headers: dict[str, str],
177 db_session: AsyncSession,
178 test_user: object,
179 ) -> None:
180 """GET with HX-Request: true returns the rows fragment, not the full page.
181
182 When HTMX issues a partial swap request it sends this header. The response
183 must NOT contain the full page chrome and MUST contain the stash row markup.
184 """
185 repo_id = await _make_repo(db_session)
186 await _make_stash(db_session, repo_id, branch="htmx-branch")
187 htmx_headers = {**auth_headers, "HX-Request": "true"}
188 resp = await client.get(
189 f"/{_OWNER}/{_SLUG}/stash", headers=htmx_headers
190 )
191 assert resp.status_code == 200
192 body = resp.text
193 assert "htmx-branch" in body
194 assert "<!DOCTYPE html>" not in body
195 assert "<html" not in body
196
197
198 @pytest.mark.anyio
199 async def test_stash_page_empty_state_when_no_stashes(
200 client: AsyncClient,
201 auth_headers: dict[str, str],
202 db_session: AsyncSession,
203 test_user: object,
204 ) -> None:
205 """Empty stash list renders an empty-state component server-side (no JS fetch needed)."""
206 await _make_repo(db_session)
207 resp = await client.get(
208 f"/{_OWNER}/{_SLUG}/stash", headers=auth_headers
209 )
210 assert resp.status_code == 200
211 body = resp.text
212 assert '<div class="stash-row"' not in body
213 assert "No stashed changes" in body or "empty-state" in body