gabriel / musehub public
test_musehub_ui_repo_home_ssr.py python
219 lines 6.7 KB
08187bdf feat: repo home UI polish, correct clone URLs, owner-handle API fix (#29) Gabriel Cardona <cgcardona@gmail.com> 2d ago
1 """SSR tests for the MuseHub repo home page (issue #575).
2
3 Covers GET /{owner}/{repo_slug} after SSR migration:
4
5 - test_repo_home_renders_repo_description_server_side
6 Seed repo with description, GET home, assert description in HTML body.
7
8 - test_repo_home_renders_file_tree_server_side
9 Seed a file tree entry via a commit object, assert filename in HTML.
10
11 - test_repo_home_branch_picker_has_hx_get
12 Branch select form carries ``hx-get`` attribute pointing to repo base URL.
13
14 - test_repo_home_htmx_fragment_on_branch_switch
15 GET with ``HX-Request: true`` → file tree fragment (no <html> wrapper).
16
17 - test_repo_home_shows_tempo_bpm
18 Repo with tempo_bpm set → BPM value appears in sidebar HTML.
19
20 - test_repo_home_empty_tree_shows_empty_state
21 Empty repo (no objects) → empty state message in HTML.
22
23 - test_repo_home_json_format_returns_json
24 GET with ``?format=json`` → JSON response with camelCase keys.
25 """
26 from __future__ import annotations
27
28 import pytest
29 from httpx import AsyncClient
30 from sqlalchemy.ext.asyncio import AsyncSession
31
32 from musehub.db.musehub_models import MusehubCommit, MusehubObject, MusehubRepo
33
34 pytestmark = pytest.mark.anyio
35
36 _OWNER = "ssr-home-owner"
37 _SLUG = "ssr-home-repo"
38
39
40 # ---------------------------------------------------------------------------
41 # Helpers
42 # ---------------------------------------------------------------------------
43
44
45 async def _make_repo(
46 db: AsyncSession,
47 *,
48 description: str = "A test music repo",
49 key_signature: str | None = None,
50 tempo_bpm: int | None = None,
51 ) -> str:
52 """Seed a minimal public repo and return its repo_id string."""
53 repo = MusehubRepo(
54 name=_SLUG,
55 owner=_OWNER,
56 slug=_SLUG,
57 visibility="public",
58 owner_user_id="ssr-home-owner-uid",
59 description=description,
60 key_signature=key_signature,
61 tempo_bpm=tempo_bpm,
62 )
63 db.add(repo)
64 await db.commit()
65 await db.refresh(repo)
66 return str(repo.repo_id)
67
68
69 async def _add_object(
70 db: AsyncSession,
71 repo_id: str,
72 path: str,
73 *,
74 size_bytes: int = 1024,
75 ) -> None:
76 """Seed a MusehubObject so the file tree has entries."""
77 import uuid
78
79 obj = MusehubObject(
80 object_id=f"sha256:{uuid.uuid4().hex}",
81 repo_id=repo_id,
82 path=path,
83 size_bytes=size_bytes,
84 disk_path=f"/tmp/test/{path}",
85 )
86 db.add(obj)
87 await db.commit()
88
89
90 # ---------------------------------------------------------------------------
91 # Tests
92 # ---------------------------------------------------------------------------
93
94
95 async def test_repo_home_renders_repo_description_server_side(
96 client: AsyncClient,
97 db_session: AsyncSession,
98 ) -> None:
99 """Seed a repo with a description, GET the home page, assert description in HTML.
100
101 The SSR migration means the description must be in the initial HTML response —
102 not fetched by JavaScript after page load.
103 """
104 description = "Jazz standards arranged for modern quartet"
105 await _make_repo(db_session, description=description)
106
107 resp = await client.get(f"/{_OWNER}/{_SLUG}")
108 assert resp.status_code == 200
109 assert description in resp.text
110
111
112 async def test_repo_home_renders_file_tree_server_side(
113 client: AsyncClient,
114 db_session: AsyncSession,
115 ) -> None:
116 """Seed a file object, GET the home page, assert filename appears in HTML.
117
118 The SSR migration means the file tree is rendered server-side.
119 This test fails if the handler omits ``tree`` from the template context.
120 """
121 repo_id = await _make_repo(db_session)
122 await _add_object(db_session, repo_id, "bass_line.mid")
123
124 resp = await client.get(f"/{_OWNER}/{_SLUG}")
125 assert resp.status_code == 200
126 assert "bass_line.mid" in resp.text
127
128
129 async def test_repo_home_branch_picker_has_hx_get(
130 client: AsyncClient,
131 db_session: AsyncSession,
132 ) -> None:
133 """Branch picker form carries ``hx-get`` for HTMX branch switching.
134
135 The form submits via HTMX (not a full page reload), targeting ``#file-tree``
136 so only the file tree updates when the user switches branches.
137 """
138 await _make_repo(db_session)
139
140 resp = await client.get(f"/{_OWNER}/{_SLUG}")
141 assert resp.status_code == 200
142 assert "hx-get" in resp.text
143
144
145 async def test_repo_home_htmx_fragment_on_branch_switch(
146 client: AsyncClient,
147 db_session: AsyncSession,
148 ) -> None:
149 """GET with ``HX-Request: true`` returns only the bare file tree fragment.
150
151 The fragment must not contain a full HTML document shell (<html>, <head>)
152 — it is swapped directly into ``#file-tree`` by HTMX on branch change.
153 """
154 repo_id = await _make_repo(db_session)
155 await _add_object(db_session, repo_id, "melody.mid")
156
157 resp = await client.get(
158 f"/{_OWNER}/{_SLUG}",
159 headers={"HX-Request": "true"},
160 )
161 assert resp.status_code == 200
162 body = resp.text
163 assert "melody.mid" in body
164 assert "<html" not in body
165 assert "<head" not in body
166
167
168 async def test_repo_home_shows_tempo_bpm(
169 client: AsyncClient,
170 db_session: AsyncSession,
171 ) -> None:
172 """Repo with tempo_bpm set → BPM value appears in the About sidebar pill.
173
174 The route passes repo_bpm to the template so music-specific metadata
175 (tempo) is visible in the About section without a client-side API call.
176 """
177 await _make_repo(db_session, tempo_bpm=132)
178
179 resp = await client.get(f"/{_OWNER}/{_SLUG}")
180 assert resp.status_code == 200
181 assert "132 BPM" in resp.text
182
183
184 async def test_repo_home_empty_tree_shows_empty_state(
185 client: AsyncClient,
186 db_session: AsyncSession,
187 ) -> None:
188 """Empty repo (no objects) renders an empty state message in the file tree area.
189
190 The file_tree fragment falls through to the empty_state macro when
191 ``tree`` is an empty list.
192 """
193 await _make_repo(db_session)
194
195 resp = await client.get(f"/{_OWNER}/{_SLUG}")
196 assert resp.status_code == 200
197 # empty_state macro renders an icon + "Empty repository" message
198 assert "Empty repository" in resp.text
199
200
201 async def test_repo_home_json_format_returns_json(
202 client: AsyncClient,
203 db_session: AsyncSession,
204 ) -> None:
205 """GET with ``?format=json`` returns a JSON response with camelCase keys.
206
207 The JSON shortcut preserves backward compatibility for API consumers
208 (agents, curl scripts) that rely on the structured repo data.
209 """
210 description = "Jazz standards for JSON test"
211 await _make_repo(db_session, description=description)
212
213 resp = await client.get(f"/{_OWNER}/{_SLUG}?format=json")
214 assert resp.status_code == 200
215 assert resp.headers["content-type"].startswith("application/json")
216 data = resp.json()
217 assert data["slug"] == _SLUG
218 assert data["owner"] == _OWNER
219 assert data["description"] == description