gabriel / musehub public
test_musehub_ui_proposal_ssr.py python
225 lines 8.6 KB
Raw
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ breaking 143 days ago
1 """SSR tests for MuseHub proposal list + proposal detail pages — issue #569.
2
3 Validates that proposal 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}/proposals:
7 - test_proposal_list_renders_title_server_side — proposal title appears in HTML
8 - test_proposal_list_open_closed_counts_in_tabs — tab counts reflect seeded proposals
9 - test_proposal_list_htmx_fragment_on_tab_switch — HX-Request: true → fragment
10
11 Covers GET /{owner}/{repo_slug}/proposals/{proposal_id}:
12 - test_proposal_detail_renders_title_server_side — proposal title in HTML server-side
13 - test_proposal_detail_renders_diff_stats — branch info in HTML
14 - test_proposal_detail_merge_button_has_hx_post — merge button has hx-post
15 - test_proposal_detail_merge_button_disabled_when_not_mergeable — closed proposal → no merge button
16 - test_proposal_detail_unknown_number_404 — non-existent proposal_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.core.genesis import compute_identity_id, compute_proposal_id, compute_repo_id
25 from musehub.db.musehub_models import MusehubProposal, MusehubRepo
26
27
28 # ---------------------------------------------------------------------------
29 # Seed helpers
30 # ---------------------------------------------------------------------------
31
32
33 async def _make_repo(
34 db: AsyncSession,
35 owner: str = "proposaldev",
36 slug: str = "proposal-ssr-album",
37 ) -> str:
38 """Seed a public repo and return its repo_id string."""
39 from datetime import datetime, timezone
40 created_at = datetime.now(tz=timezone.utc)
41 owner_id = compute_identity_id(owner.encode())
42 repo_id = compute_repo_id(owner_id, slug, "code", created_at.isoformat())
43 repo = MusehubRepo(
44 repo_id=repo_id,
45 name=slug,
46 owner=owner,
47 slug=slug,
48 visibility="public",
49 owner_user_id=owner_id,
50 created_at=created_at,
51 updated_at=created_at,
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_proposal(
60 db: AsyncSession,
61 repo_id: str,
62 *,
63 proposal_number: int = 1,
64 title: str = "Add bossa nova bridge",
65 body: str = "Adds a new bossa nova bridge section.",
66 state: str = "open",
67 from_branch: str = "feat/bossa-nova",
68 to_branch: str = "main",
69 author: str = "beatmaker",
70 ) -> MusehubProposal:
71 """Seed a proposal and return the ORM object."""
72 from datetime import datetime, timezone
73 author_id = compute_identity_id(author.encode())
74 proposal = MusehubProposal(
75 proposal_id=compute_proposal_id(repo_id, author_id, from_branch, to_branch, datetime.now(tz=timezone.utc).isoformat()),
76 repo_id=repo_id,
77 proposal_number=proposal_number,
78 title=title,
79 body=body,
80 state=state,
81 from_branch=from_branch,
82 to_branch=to_branch,
83 author=author,
84 )
85 db.add(proposal)
86 await db.commit()
87 await db.refresh(proposal)
88 return proposal
89
90
91 # ---------------------------------------------------------------------------
92 # Proposal list SSR tests
93 # ---------------------------------------------------------------------------
94
95
96 async def test_proposal_list_renders_title_server_side(
97 client: AsyncClient,
98 db_session: AsyncSession,
99 ) -> None:
100 """Proposal title is rendered into the HTML response server-side without client JS."""
101 repo_id = await _make_repo(db_session)
102 await _make_proposal(db_session, repo_id, title="Funk bridge with wah pedal")
103 response = await client.get("/proposaldev/proposal-ssr-album/proposals")
104 assert response.status_code == 200
105 assert "text/html" in response.headers["content-type"]
106 assert "Funk bridge with wah pedal" in response.text
107
108
109 async def test_proposal_list_open_closed_counts_in_tabs(
110 client: AsyncClient,
111 db_session: AsyncSession,
112 ) -> None:
113 """State tabs display SSR-computed open/merged/closed counts."""
114 repo_id = await _make_repo(db_session)
115 await _make_proposal(db_session, repo_id, proposal_number=1, title="Open proposal 1", state="open")
116 await _make_proposal(db_session, repo_id, proposal_number=2, title="Open proposal 2", state="open")
117 await _make_proposal(db_session, repo_id, proposal_number=3, title="Merged proposal", state="merged")
118 response = await client.get("/proposaldev/proposal-ssr-album/proposals")
119 assert response.status_code == 200
120 body = response.text
121 # Tab counts for open and merged must appear as server-rendered numbers.
122 assert "2" in body # open_count
123 assert "1" in body # merged_count
124
125
126 async def test_proposal_list_htmx_fragment_on_tab_switch(
127 client: AsyncClient,
128 db_session: AsyncSession,
129 ) -> None:
130 """HX-Request: true with state=merged returns a bare HTML fragment."""
131 repo_id = await _make_repo(db_session)
132 await _make_proposal(db_session, repo_id, title="Merged feature", state="merged")
133 response = await client.get(
134 "/proposaldev/proposal-ssr-album/proposals?state=merged",
135 headers={"HX-Request": "true"},
136 )
137 assert response.status_code == 200
138 body = response.text
139 # Fragment must NOT contain the full HTML page shell.
140 assert "<html" not in body
141 assert "<head" not in body
142 # Proposal title must appear in the fragment.
143 assert "Merged feature" in body
144
145
146 # ---------------------------------------------------------------------------
147 # Proposal detail SSR tests
148 # ---------------------------------------------------------------------------
149
150
151 async def test_proposal_detail_renders_title_server_side(
152 client: AsyncClient,
153 db_session: AsyncSession,
154 ) -> None:
155 """Proposal title and branch info appear in the detail page HTML server-side."""
156 repo_id = await _make_repo(db_session)
157 proposal = await _make_proposal(
158 db_session, repo_id, title="Add jazz chord voicings", from_branch="feat/jazz"
159 )
160 response = await client.get(f"/proposaldev/proposal-ssr-album/proposals/{proposal.proposal_id}")
161 assert response.status_code == 200
162 assert "text/html" in response.headers["content-type"]
163 assert "Add jazz chord voicings" in response.text
164
165
166 async def test_proposal_detail_renders_diff_stats(
167 client: AsyncClient,
168 db_session: AsyncSession,
169 ) -> None:
170 """Branch names (from_branch / to_branch) appear in the detail page HTML."""
171 repo_id = await _make_repo(db_session)
172 proposal = await _make_proposal(
173 db_session,
174 repo_id,
175 title="Bass groove proposal",
176 from_branch="feat/bass-groove",
177 to_branch="dev",
178 )
179 response = await client.get(f"/proposaldev/proposal-ssr-album/proposals/{proposal.proposal_id}")
180 assert response.status_code == 200
181 body = response.text
182 # Both branch names must appear in the server-rendered HTML.
183 assert "feat/bass-groove" in body
184 assert "dev" in body
185
186
187 async def test_proposal_detail_merge_button_has_hx_post(
188 client: AsyncClient,
189 db_session: AsyncSession,
190 ) -> None:
191 """An open proposal detail page includes a merge button with an hx-post attribute."""
192 repo_id = await _make_repo(db_session)
193 proposal = await _make_proposal(db_session, repo_id, title="Merge-ready proposal", state="open")
194 response = await client.get(f"/proposaldev/proposal-ssr-album/proposals/{proposal.proposal_id}")
195 assert response.status_code == 200
196 body = response.text
197 # The merge card must have at least one HTMX POST trigger.
198 assert "hx-post" in body
199 assert "merge" in body.lower()
200
201
202 async def test_proposal_detail_merge_button_disabled_when_not_mergeable(
203 client: AsyncClient,
204 db_session: AsyncSession,
205 ) -> None:
206 """A closed or merged proposal does not show the merge button."""
207 repo_id = await _make_repo(db_session)
208 proposal = await _make_proposal(db_session, repo_id, title="Already Merged proposal", state="merged")
209 response = await client.get(f"/proposaldev/proposal-ssr-album/proposals/{proposal.proposal_id}")
210 assert response.status_code == 200
211 body = response.text
212 # Merged/closed proposals must not render the merge action form.
213 assert "Merge merge proposal" not in body
214
215
216 async def test_proposal_detail_unknown_number_404(
217 client: AsyncClient,
218 db_session: AsyncSession,
219 ) -> None:
220 """A request for a non-existent proposal id returns HTTP 404."""
221 await _make_repo(db_session)
222 response = await client.get(
223 "/proposaldev/proposal-ssr-album/proposals/nonexistent-proposal-uuid"
224 )
225 assert response.status_code == 404
File History 1 commit
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor 143 days ago