gabriel / musehub public
test_musehub_ui_issue_detail_ssr.py python
302 lines 8.4 KB
c0f0b481 release: merge dev → main (#5) Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """Tests for the SSR issue detail page — HTMX SSR + comment threading (issue #568).
2
3 Covers server-side rendering of issue body, comment thread, HTMX fragment
4 responses, status action buttons, sidebar, and 404 handling.
5
6 Test areas:
7 Basic rendering
8 - test_issue_detail_renders_title_server_side
9 - test_issue_detail_unknown_number_404
10
11 SSR body content
12 - test_issue_detail_renders_body_markdown
13 - test_issue_detail_empty_body_shows_placeholder
14
15 Comments
16 - test_issue_detail_renders_comments_server_side
17 - test_issue_detail_no_comments_shows_placeholder
18
19 HTMX attributes
20 - test_issue_detail_comment_form_has_hx_post
21 - test_issue_detail_close_button_has_hx_post
22 - test_issue_detail_reopen_button_has_hx_post
23
24 HTMX fragment
25 - test_issue_detail_htmx_request_returns_comment_fragment
26 """
27 from __future__ import annotations
28
29 import pytest
30 from httpx import AsyncClient
31 from sqlalchemy.ext.asyncio import AsyncSession
32
33 from musehub.db.musehub_models import MusehubIssue, MusehubIssueComment, MusehubRepo
34
35
36 # ---------------------------------------------------------------------------
37 # Helpers
38 # ---------------------------------------------------------------------------
39
40
41 async def _make_repo(
42 db: AsyncSession,
43 owner: str = "songwriter",
44 slug: str = "melodies",
45 ) -> str:
46 """Seed a public repo and return its repo_id string."""
47 repo = MusehubRepo(
48 name=slug,
49 owner=owner,
50 slug=slug,
51 visibility="public",
52 owner_user_id="uid-songwriter",
53 )
54 db.add(repo)
55 await db.commit()
56 await db.refresh(repo)
57 return str(repo.repo_id)
58
59
60 async def _make_issue(
61 db: AsyncSession,
62 repo_id: str,
63 *,
64 number: int = 1,
65 title: str = "Verse needs a bridge",
66 body: str = "The verse feels incomplete.",
67 state: str = "open",
68 author: str = "songwriter",
69 labels: list[str] | None = None,
70 ) -> MusehubIssue:
71 """Seed an issue and return it."""
72 issue = MusehubIssue(
73 repo_id=repo_id,
74 number=number,
75 title=title,
76 body=body,
77 state=state,
78 labels=labels or [],
79 author=author,
80 )
81 db.add(issue)
82 await db.commit()
83 await db.refresh(issue)
84 return issue
85
86
87 async def _make_comment(
88 db: AsyncSession,
89 issue_id: str,
90 repo_id: str,
91 *,
92 author: str = "producer",
93 body: str = "Good point.",
94 parent_id: str | None = None,
95 ) -> MusehubIssueComment:
96 """Seed a comment and return it."""
97 comment = MusehubIssueComment(
98 issue_id=issue_id,
99 repo_id=repo_id,
100 author=author,
101 body=body,
102 parent_id=parent_id,
103 musical_refs=[],
104 )
105 db.add(comment)
106 await db.commit()
107 await db.refresh(comment)
108 return comment
109
110
111 async def _get_detail(
112 client: AsyncClient,
113 number: int = 1,
114 owner: str = "songwriter",
115 slug: str = "melodies",
116 headers: dict[str, str] | None = None,
117 ) -> tuple[int, str]:
118 """Fetch the issue detail page; return (status_code, body_text)."""
119 resp = await client.get(
120 f"/{owner}/{slug}/issues/{number}",
121 headers=headers or {},
122 )
123 return resp.status_code, resp.text
124
125
126 # ---------------------------------------------------------------------------
127 # Basic rendering
128 # ---------------------------------------------------------------------------
129
130
131 @pytest.mark.anyio
132 async def test_issue_detail_renders_title_server_side(
133 client: AsyncClient,
134 db_session: AsyncSession,
135 ) -> None:
136 """Issue title appears in the HTML rendered on the server."""
137 repo_id = await _make_repo(db_session)
138 await _make_issue(db_session, repo_id, title="Chorus hook is off-key")
139
140 status, body = await _get_detail(client)
141
142 assert status == 200
143 assert "Chorus hook is off-key" in body
144
145
146 @pytest.mark.anyio
147 async def test_issue_detail_unknown_number_404(
148 client: AsyncClient,
149 db_session: AsyncSession,
150 ) -> None:
151 """A non-existent issue number returns 404."""
152 await _make_repo(db_session)
153
154 resp = await client.get("/songwriter/melodies/issues/999")
155 assert resp.status_code == 404
156
157
158 # ---------------------------------------------------------------------------
159 # SSR body content
160 # ---------------------------------------------------------------------------
161
162
163 @pytest.mark.anyio
164 async def test_issue_detail_renders_body_markdown(
165 client: AsyncClient,
166 db_session: AsyncSession,
167 ) -> None:
168 """Issue body with Markdown bold is rendered as <strong> in the HTML."""
169 repo_id = await _make_repo(db_session)
170 await _make_issue(db_session, repo_id, body="The **bass line** needs work.")
171
172 status, body = await _get_detail(client)
173
174 assert status == 200
175 assert "<strong>bass line</strong>" in body
176
177
178 @pytest.mark.anyio
179 async def test_issue_detail_empty_body_shows_placeholder(
180 client: AsyncClient,
181 db_session: AsyncSession,
182 ) -> None:
183 """An issue with empty body renders the 'No description provided' placeholder."""
184 repo_id = await _make_repo(db_session)
185 await _make_issue(db_session, repo_id, body="")
186
187 status, body = await _get_detail(client)
188
189 assert status == 200
190 assert "No description provided" in body
191
192
193 # ---------------------------------------------------------------------------
194 # Comments
195 # ---------------------------------------------------------------------------
196
197
198 @pytest.mark.anyio
199 async def test_issue_detail_renders_comments_server_side(
200 client: AsyncClient,
201 db_session: AsyncSession,
202 ) -> None:
203 """A seeded comment body appears in the rendered HTML."""
204 repo_id = await _make_repo(db_session)
205 issue = await _make_issue(db_session, repo_id)
206 await _make_comment(db_session, issue.issue_id, repo_id, body="Agreed, bridge it up!")
207
208 status, body = await _get_detail(client)
209
210 assert status == 200
211 assert "Agreed, bridge it up!" in body
212
213
214 @pytest.mark.anyio
215 async def test_issue_detail_no_comments_shows_placeholder(
216 client: AsyncClient,
217 db_session: AsyncSession,
218 ) -> None:
219 """When there are no comments the placeholder text is rendered."""
220 repo_id = await _make_repo(db_session)
221 await _make_issue(db_session, repo_id)
222
223 status, body = await _get_detail(client)
224
225 assert status == 200
226 assert "No comments yet" in body
227
228
229 # ---------------------------------------------------------------------------
230 # HTMX attributes
231 # ---------------------------------------------------------------------------
232
233
234 @pytest.mark.anyio
235 async def test_issue_detail_comment_form_has_hx_post(
236 client: AsyncClient,
237 db_session: AsyncSession,
238 ) -> None:
239 """The comment form exposes an hx-post attribute for HTMX submission."""
240 repo_id = await _make_repo(db_session)
241 await _make_issue(db_session, repo_id)
242
243 status, body = await _get_detail(client)
244
245 assert status == 200
246 assert "hx-post" in body
247
248
249 @pytest.mark.anyio
250 async def test_issue_detail_close_button_has_hx_post(
251 client: AsyncClient,
252 db_session: AsyncSession,
253 ) -> None:
254 """An open issue renders a close button form with hx-post."""
255 repo_id = await _make_repo(db_session)
256 await _make_issue(db_session, repo_id, state="open")
257
258 status, body = await _get_detail(client)
259
260 assert status == 200
261 assert "Close issue" in body
262 assert "hx-post" in body
263
264
265 @pytest.mark.anyio
266 async def test_issue_detail_reopen_button_has_hx_post(
267 client: AsyncClient,
268 db_session: AsyncSession,
269 ) -> None:
270 """A closed issue renders a reopen button form with hx-post."""
271 repo_id = await _make_repo(db_session)
272 await _make_issue(db_session, repo_id, state="closed")
273
274 status, body = await _get_detail(client)
275
276 assert status == 200
277 assert "Reopen issue" in body
278 assert "hx-post" in body
279
280
281 # ---------------------------------------------------------------------------
282 # HTMX fragment
283 # ---------------------------------------------------------------------------
284
285
286 @pytest.mark.anyio
287 async def test_issue_detail_htmx_request_returns_comment_fragment(
288 client: AsyncClient,
289 db_session: AsyncSession,
290 ) -> None:
291 """GET with HX-Request: true returns the comment fragment (no full page shell)."""
292 repo_id = await _make_repo(db_session)
293 issue = await _make_issue(db_session, repo_id)
294 await _make_comment(db_session, issue.issue_id, repo_id, body="Fragment comment here.")
295
296 status, body = await _get_detail(client, headers={"HX-Request": "true"})
297
298 assert status == 200
299 assert "Fragment comment here." in body
300 # Fragment must not include the full page chrome
301 assert "<html" not in body
302 assert "<!DOCTYPE" not in body