gabriel / musehub public
test_musehub_ui_pr_ssr.py python
220 lines 7.5 KB
c0f0b481 release: merge dev → main (#5) Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """SSR tests for MuseHub PR list + PR detail pages — issue #569.
2
3 Validates that PR data is rendered server-side into HTML (not deferred to client
4 JS) and that HTMX fragment requests return bare HTML without the full page shell.
5
6 Covers GET /{owner}/{repo_slug}/pulls:
7 - test_pr_list_renders_pr_title_server_side — PR title appears in HTML
8 - test_pr_list_open_closed_counts_in_tabs — tab counts reflect seeded PRs
9 - test_pr_list_htmx_fragment_on_tab_switch — HX-Request: true → fragment
10
11 Covers GET /{owner}/{repo_slug}/pulls/{pr_id}:
12 - test_pr_detail_renders_title_server_side — PR title in HTML server-side
13 - test_pr_detail_renders_diff_stats — branch info in HTML
14 - test_pr_detail_merge_button_has_hx_post — merge button has hx-post
15 - test_pr_detail_merge_button_disabled_when_not_mergeable — closed PR → no merge button
16 - test_pr_detail_unknown_number_404 — non-existent pr_id → 404
17 """
18 from __future__ import annotations
19
20 import pytest
21 from httpx import AsyncClient
22 from sqlalchemy.ext.asyncio import AsyncSession
23
24 from musehub.db.musehub_models import MusehubPullRequest, MusehubRepo
25
26
27 # ---------------------------------------------------------------------------
28 # Seed helpers
29 # ---------------------------------------------------------------------------
30
31
32 async def _make_repo(
33 db: AsyncSession,
34 owner: str = "prdev",
35 slug: str = "pr-ssr-album",
36 ) -> str:
37 """Seed a public repo and return its repo_id string."""
38 repo = MusehubRepo(
39 name=slug,
40 owner=owner,
41 slug=slug,
42 visibility="public",
43 owner_user_id="uid-pr-ssr-dev",
44 )
45 db.add(repo)
46 await db.commit()
47 await db.refresh(repo)
48 return str(repo.repo_id)
49
50
51 async def _make_pr(
52 db: AsyncSession,
53 repo_id: str,
54 *,
55 title: str = "Add bossa nova bridge",
56 body: str = "Adds a new bossa nova bridge section.",
57 state: str = "open",
58 from_branch: str = "feat/bossa-nova",
59 to_branch: str = "main",
60 author: str = "beatmaker",
61 ) -> MusehubPullRequest:
62 """Seed a PR and return the ORM object."""
63 pr = MusehubPullRequest(
64 repo_id=repo_id,
65 title=title,
66 body=body,
67 state=state,
68 from_branch=from_branch,
69 to_branch=to_branch,
70 author=author,
71 )
72 db.add(pr)
73 await db.commit()
74 await db.refresh(pr)
75 return pr
76
77
78 # ---------------------------------------------------------------------------
79 # PR list SSR tests
80 # ---------------------------------------------------------------------------
81
82
83 @pytest.mark.anyio
84 async def test_pr_list_renders_pr_title_server_side(
85 client: AsyncClient,
86 db_session: AsyncSession,
87 ) -> None:
88 """PR title is rendered into the HTML response server-side without client JS."""
89 repo_id = await _make_repo(db_session)
90 await _make_pr(db_session, repo_id, title="Funk bridge with wah pedal")
91 response = await client.get("/prdev/pr-ssr-album/pulls")
92 assert response.status_code == 200
93 assert "text/html" in response.headers["content-type"]
94 assert "Funk bridge with wah pedal" in response.text
95
96
97 @pytest.mark.anyio
98 async def test_pr_list_open_closed_counts_in_tabs(
99 client: AsyncClient,
100 db_session: AsyncSession,
101 ) -> None:
102 """State tabs display SSR-computed open/merged/closed counts."""
103 repo_id = await _make_repo(db_session)
104 await _make_pr(db_session, repo_id, title="Open PR 1", state="open")
105 await _make_pr(db_session, repo_id, title="Open PR 2", state="open")
106 await _make_pr(db_session, repo_id, title="Merged PR", state="merged")
107 response = await client.get("/prdev/pr-ssr-album/pulls")
108 assert response.status_code == 200
109 body = response.text
110 # Tab counts for open and merged must appear as server-rendered numbers.
111 assert "2" in body # open_count
112 assert "1" in body # merged_count
113
114
115 @pytest.mark.anyio
116 async def test_pr_list_htmx_fragment_on_tab_switch(
117 client: AsyncClient,
118 db_session: AsyncSession,
119 ) -> None:
120 """HX-Request: true with state=merged returns a bare HTML fragment."""
121 repo_id = await _make_repo(db_session)
122 await _make_pr(db_session, repo_id, title="Merged feature", state="merged")
123 response = await client.get(
124 "/prdev/pr-ssr-album/pulls?state=merged",
125 headers={"HX-Request": "true"},
126 )
127 assert response.status_code == 200
128 body = response.text
129 # Fragment must NOT contain the full HTML page shell.
130 assert "<html" not in body
131 assert "<head" not in body
132 # PR title must appear in the fragment.
133 assert "Merged feature" in body
134
135
136 # ---------------------------------------------------------------------------
137 # PR detail SSR tests
138 # ---------------------------------------------------------------------------
139
140
141 @pytest.mark.anyio
142 async def test_pr_detail_renders_title_server_side(
143 client: AsyncClient,
144 db_session: AsyncSession,
145 ) -> None:
146 """PR title and branch info appear in the detail page HTML server-side."""
147 repo_id = await _make_repo(db_session)
148 pr = await _make_pr(
149 db_session, repo_id, title="Add jazz chord voicings", from_branch="feat/jazz"
150 )
151 response = await client.get(f"/prdev/pr-ssr-album/pulls/{pr.pr_id}")
152 assert response.status_code == 200
153 assert "text/html" in response.headers["content-type"]
154 assert "Add jazz chord voicings" in response.text
155
156
157 @pytest.mark.anyio
158 async def test_pr_detail_renders_diff_stats(
159 client: AsyncClient,
160 db_session: AsyncSession,
161 ) -> None:
162 """Branch names (from_branch / to_branch) appear in the detail page HTML."""
163 repo_id = await _make_repo(db_session)
164 pr = await _make_pr(
165 db_session,
166 repo_id,
167 title="Bass groove PR",
168 from_branch="feat/bass-groove",
169 to_branch="dev",
170 )
171 response = await client.get(f"/prdev/pr-ssr-album/pulls/{pr.pr_id}")
172 assert response.status_code == 200
173 body = response.text
174 # Both branch names must appear in the server-rendered HTML.
175 assert "feat/bass-groove" in body
176 assert "dev" in body
177
178
179 @pytest.mark.anyio
180 async def test_pr_detail_merge_button_has_hx_post(
181 client: AsyncClient,
182 db_session: AsyncSession,
183 ) -> None:
184 """An open PR detail page includes a merge button with an hx-post attribute."""
185 repo_id = await _make_repo(db_session)
186 pr = await _make_pr(db_session, repo_id, title="Merge-ready PR", state="open")
187 response = await client.get(f"/prdev/pr-ssr-album/pulls/{pr.pr_id}")
188 assert response.status_code == 200
189 body = response.text
190 # The merge card must have at least one HTMX POST trigger.
191 assert "hx-post" in body
192 assert "merge" in body.lower()
193
194
195 @pytest.mark.anyio
196 async def test_pr_detail_merge_button_disabled_when_not_mergeable(
197 client: AsyncClient,
198 db_session: AsyncSession,
199 ) -> None:
200 """A closed or merged PR does not show the merge button."""
201 repo_id = await _make_repo(db_session)
202 pr = await _make_pr(db_session, repo_id, title="Already Merged PR", state="merged")
203 response = await client.get(f"/prdev/pr-ssr-album/pulls/{pr.pr_id}")
204 assert response.status_code == 200
205 body = response.text
206 # Merged/closed PRs must not render the merge action form.
207 assert "Merge pull request" not in body
208
209
210 @pytest.mark.anyio
211 async def test_pr_detail_unknown_number_404(
212 client: AsyncClient,
213 db_session: AsyncSession,
214 ) -> None:
215 """A request for a non-existent PR id returns HTTP 404."""
216 await _make_repo(db_session)
217 response = await client.get(
218 "/prdev/pr-ssr-album/pulls/nonexistent-pr-uuid"
219 )
220 assert response.status_code == 404