gabriel / musehub public
test_musehub_discover.py python
388 lines 14.0 KB
cd448303 Initial extraction of MuseHub from maestro monorepo. Gabriel Cardona <gabriel@tellurstori.com> 7d ago
1 """Tests for the Muse Hub explore/discover API endpoints.
2
3 Covers acceptance criteria:
4 - test_explore_page_renders — GET /musehub/ui/explore returns 200 HTML
5 - test_trending_page_renders — GET /musehub/ui/trending returns 200 HTML
6 - test_list_public_repos_empty — no public repos → empty list
7 - test_explore_only_public_repos — private repos are excluded from results
8 - test_explore_filters_by_genre — genre tag filter works
9 - test_explore_filters_by_key — key_signature exact filter works
10 - test_explore_filters_by_tempo — tempo_min/tempo_max range filter works
11 - test_explore_filters_by_instrumentation — instrumentation tag filter works
12 - test_explore_sorts_by_stars — star-count sort returns highest-starred first
13 - test_explore_sorts_by_created — created sort returns newest first
14 - test_explore_pagination — page 2 returns different repos
15 - test_star_repo_requires_auth — POST /star returns 401 without JWT
16 - test_star_repo_adds_star — star increments star_count
17 - test_star_repo_idempotent — duplicate star is silent
18 - test_unstar_repo_removes_star — unstar decrements star_count
19 - test_unstar_repo_idempotent — unstarring twice is a no-op
20 - test_star_private_repo_returns_404 — cannot star a private repo
21 """
22 from __future__ import annotations
23
24 import pytest
25 from httpx import AsyncClient
26 from sqlalchemy.ext.asyncio import AsyncSession
27
28 from musehub.db.musehub_models import MusehubRepo, MusehubStar
29
30
31 # ---------------------------------------------------------------------------
32 # Helpers
33 # ---------------------------------------------------------------------------
34
35
36 async def _make_public_repo(
37 db_session: AsyncSession,
38 *,
39 name: str = "test-jazz-repo",
40 tags: list[str] | None = None,
41 key_signature: str | None = None,
42 tempo_bpm: int | None = None,
43 description: str = "",
44 ) -> str:
45 """Seed a public repo and return its repo_id."""
46 import re as _re
47 slug = _re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:64].strip("-") or "repo"
48 repo = MusehubRepo(
49 name=name,
50 owner="testuser",
51 slug=slug,
52 visibility="public",
53 owner_user_id="test-owner",
54 description=description,
55 tags=tags or [],
56 key_signature=key_signature,
57 tempo_bpm=tempo_bpm,
58 )
59 db_session.add(repo)
60 await db_session.commit()
61 await db_session.refresh(repo)
62 return str(repo.repo_id)
63
64
65 async def _make_private_repo(db_session: AsyncSession, name: str = "private-beats") -> str:
66 """Seed a private repo and return its repo_id."""
67 import re as _re
68 slug = _re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:64].strip("-") or "repo"
69 repo = MusehubRepo(
70 name=name,
71 owner="testuser",
72 slug=slug,
73 visibility="private",
74 owner_user_id="test-owner",
75 description="",
76 tags=[],
77 )
78 db_session.add(repo)
79 await db_session.commit()
80 await db_session.refresh(repo)
81 return str(repo.repo_id)
82
83
84 # ---------------------------------------------------------------------------
85 # UI page tests (no auth required)
86 # ---------------------------------------------------------------------------
87
88
89 @pytest.mark.anyio
90 async def test_explore_page_renders(client: AsyncClient) -> None:
91 """GET /musehub/ui/explore returns 200 HTML with filter controls."""
92 response = await client.get("/musehub/ui/explore")
93 assert response.status_code == 200
94 assert "text/html" in response.headers["content-type"]
95 body = response.text
96 assert "Muse Hub" in body
97 assert "Explore" in body
98 # Filter controls must be present
99 assert "genre-inp" in body
100 assert "key-inp" in body
101 assert "tempo-min" in body
102 assert "sort-sel" in body
103 # Discover API endpoint must be referenced
104 assert "discover/repos" in body
105
106
107 @pytest.mark.anyio
108 async def test_trending_page_renders(client: AsyncClient) -> None:
109 """GET /musehub/ui/trending returns 200 HTML with stars sort pre-selected."""
110 response = await client.get("/musehub/ui/trending")
111 assert response.status_code == 200
112 assert "text/html" in response.headers["content-type"]
113 body = response.text
114 assert "Muse Hub" in body
115 assert "Trending" in body
116 # Stars sort option must be pre-selected on the trending page
117 assert 'value="stars" selected' in body or "selected" in body
118 assert "discover/repos" in body
119
120
121 @pytest.mark.anyio
122 async def test_explore_page_no_auth_required(client: AsyncClient) -> None:
123 """GET /musehub/ui/explore must not return 401 — it is a public page."""
124 response = await client.get("/musehub/ui/explore")
125 assert response.status_code == 200
126
127
128 # ---------------------------------------------------------------------------
129 # JSON API tests — public browse endpoint (no auth)
130 # ---------------------------------------------------------------------------
131
132
133 @pytest.mark.anyio
134 async def test_list_public_repos_empty(client: AsyncClient, db_session: AsyncSession) -> None:
135 """GET /api/v1/musehub/discover/repos returns empty list when no public repos exist."""
136 response = await client.get("/api/v1/musehub/discover/repos")
137 assert response.status_code == 200
138 body = response.json()
139 assert body["repos"] == []
140 assert body["total"] == 0
141 assert body["page"] == 1
142 assert body["pageSize"] == 24
143
144
145 @pytest.mark.anyio
146 async def test_explore_only_public_repos(
147 client: AsyncClient, db_session: AsyncSession
148 ) -> None:
149 """Private repos must not appear in discover results."""
150 await _make_public_repo(db_session, name="public-one")
151 await _make_private_repo(db_session, name="private-one")
152
153 response = await client.get("/api/v1/musehub/discover/repos")
154 assert response.status_code == 200
155 body = response.json()
156 assert body["total"] == 1
157 names = [r["name"] for r in body["repos"]]
158 assert "public-one" in names
159 assert "private-one" not in names
160
161
162 @pytest.mark.anyio
163 async def test_explore_filters_by_genre(
164 client: AsyncClient, db_session: AsyncSession
165 ) -> None:
166 """genre= filter returns only repos whose tags contain the genre string."""
167 await _make_public_repo(db_session, name="jazz-project", tags=["jazz", "swing"])
168 await _make_public_repo(db_session, name="lofi-project", tags=["lo-fi", "chill"])
169
170 response = await client.get("/api/v1/musehub/discover/repos?genre=jazz")
171 assert response.status_code == 200
172 body = response.json()
173 assert body["total"] == 1
174 assert body["repos"][0]["name"] == "jazz-project"
175
176
177 @pytest.mark.anyio
178 async def test_explore_filters_by_key(
179 client: AsyncClient, db_session: AsyncSession
180 ) -> None:
181 """key= filter returns only repos with the matching key_signature."""
182 await _make_public_repo(db_session, name="fsharp-minor", key_signature="F# minor")
183 await _make_public_repo(db_session, name="c-major", key_signature="C major")
184
185 response = await client.get("/api/v1/musehub/discover/repos?key=F%23+minor")
186 assert response.status_code == 200
187 body = response.json()
188 assert body["total"] == 1
189 assert body["repos"][0]["name"] == "fsharp-minor"
190
191
192 @pytest.mark.anyio
193 async def test_explore_filters_by_tempo(
194 client: AsyncClient, db_session: AsyncSession
195 ) -> None:
196 """tempo_min and tempo_max filter repos by BPM range."""
197 await _make_public_repo(db_session, name="slow", tempo_bpm=70)
198 await _make_public_repo(db_session, name="mid", tempo_bpm=100)
199 await _make_public_repo(db_session, name="fast", tempo_bpm=150)
200
201 response = await client.get("/api/v1/musehub/discover/repos?tempo_min=90&tempo_max=120")
202 assert response.status_code == 200
203 body = response.json()
204 assert body["total"] == 1
205 assert body["repos"][0]["name"] == "mid"
206
207
208 @pytest.mark.anyio
209 async def test_explore_filters_by_instrumentation(
210 client: AsyncClient, db_session: AsyncSession
211 ) -> None:
212 """instrumentation= filter matches repos whose tags include the instrument."""
213 await _make_public_repo(db_session, name="bass-heavy", tags=["jazz", "bass", "drums"])
214 await _make_public_repo(db_session, name="keys-only", tags=["ambient", "keys"])
215
216 response = await client.get("/api/v1/musehub/discover/repos?instrumentation=bass")
217 assert response.status_code == 200
218 body = response.json()
219 assert body["total"] == 1
220 assert body["repos"][0]["name"] == "bass-heavy"
221
222
223 @pytest.mark.anyio
224 async def test_explore_sorts_by_stars(
225 client: AsyncClient, db_session: AsyncSession, auth_headers: dict[str, str]
226 ) -> None:
227 """sort=stars returns the repo with more stars first."""
228 repo_a = await _make_public_repo(db_session, name="repo-a")
229 repo_b = await _make_public_repo(db_session, name="repo-b")
230
231 # Star repo_b twice (from different users) so it has more stars
232 star1 = MusehubStar(repo_id=repo_b, user_id="user-1")
233 star2 = MusehubStar(repo_id=repo_b, user_id="user-2")
234 star3 = MusehubStar(repo_id=repo_a, user_id="user-3")
235 db_session.add_all([star1, star2, star3])
236 await db_session.commit()
237
238 response = await client.get("/api/v1/musehub/discover/repos?sort=stars")
239 assert response.status_code == 200
240 body = response.json()
241 repos = body["repos"]
242 assert len(repos) == 2
243 # repo-b has 2 stars; must come first
244 assert repos[0]["name"] == "repo-b"
245 assert repos[0]["starCount"] == 2
246 assert repos[1]["name"] == "repo-a"
247 assert repos[1]["starCount"] == 1
248
249
250 @pytest.mark.anyio
251 async def test_explore_sorts_by_created(
252 client: AsyncClient, db_session: AsyncSession
253 ) -> None:
254 """sort=created returns newest repos first (default sort)."""
255 await _make_public_repo(db_session, name="first-created")
256 await _make_public_repo(db_session, name="second-created")
257
258 response = await client.get("/api/v1/musehub/discover/repos?sort=created")
259 assert response.status_code == 200
260 body = response.json()
261 # Newest first — second-created was inserted last
262 names = [r["name"] for r in body["repos"]]
263 assert names.index("second-created") < names.index("first-created")
264
265
266 @pytest.mark.anyio
267 async def test_explore_pagination(
268 client: AsyncClient, db_session: AsyncSession
269 ) -> None:
270 """Page 2 returns a different set of repos than page 1."""
271 for i in range(5):
272 await _make_public_repo(db_session, name=f"repo-{i:02d}")
273
274 page1 = (await client.get("/api/v1/musehub/discover/repos?page=1&page_size=3")).json()
275 page2 = (await client.get("/api/v1/musehub/discover/repos?page=2&page_size=3")).json()
276
277 assert page1["total"] == 5
278 assert page2["total"] == 5
279 page1_ids = {r["repoId"] for r in page1["repos"]}
280 page2_ids = {r["repoId"] for r in page2["repos"]}
281 # Pages must not overlap
282 assert not page1_ids & page2_ids
283
284
285 @pytest.mark.anyio
286 async def test_explore_invalid_sort_returns_422(
287 client: AsyncClient, db_session: AsyncSession
288 ) -> None:
289 """sort= with an invalid value returns 422 Unprocessable Entity."""
290 response = await client.get("/api/v1/musehub/discover/repos?sort=invalid")
291 assert response.status_code == 422
292
293
294 # ---------------------------------------------------------------------------
295 # Star / unstar tests (auth required)
296 # ---------------------------------------------------------------------------
297
298
299 @pytest.mark.anyio
300 async def test_star_repo_requires_auth(
301 client: AsyncClient, db_session: AsyncSession
302 ) -> None:
303 """POST /api/v1/musehub/repos/{repo_id}/star returns 401 without a JWT."""
304 repo_id = await _make_public_repo(db_session)
305 response = await client.post(f"/api/v1/musehub/repos/{repo_id}/star")
306 assert response.status_code == 401
307
308
309 @pytest.mark.anyio
310 async def test_star_repo_adds_star(
311 client: AsyncClient,
312 db_session: AsyncSession,
313 auth_headers: dict[str, str],
314 ) -> None:
315 """POST /api/v1/musehub/repos/{repo_id}/star returns starred=True and correct count."""
316 repo_id = await _make_public_repo(db_session)
317 response = await client.post(
318 f"/api/v1/musehub/repos/{repo_id}/star",
319 headers=auth_headers,
320 )
321 assert response.status_code == 200
322 body = response.json()
323 assert body["starred"] is True
324 assert body["starCount"] == 1
325
326
327 @pytest.mark.anyio
328 async def test_star_repo_idempotent(
329 client: AsyncClient,
330 db_session: AsyncSession,
331 auth_headers: dict[str, str],
332 ) -> None:
333 """Starring the same repo twice does not create duplicate stars."""
334 repo_id = await _make_public_repo(db_session)
335 await client.post(f"/api/v1/musehub/repos/{repo_id}/star", headers=auth_headers)
336 response = await client.post(
337 f"/api/v1/musehub/repos/{repo_id}/star", headers=auth_headers
338 )
339 assert response.status_code == 200
340 assert response.json()["starCount"] == 1
341
342
343 @pytest.mark.anyio
344 async def test_unstar_repo_removes_star(
345 client: AsyncClient,
346 db_session: AsyncSession,
347 auth_headers: dict[str, str],
348 ) -> None:
349 """DELETE .../star after starring reduces star_count to 0."""
350 repo_id = await _make_public_repo(db_session)
351 await client.post(f"/api/v1/musehub/repos/{repo_id}/star", headers=auth_headers)
352
353 response = await client.delete(
354 f"/api/v1/musehub/repos/{repo_id}/star", headers=auth_headers
355 )
356 assert response.status_code == 200
357 body = response.json()
358 assert body["starred"] is False
359 assert body["starCount"] == 0
360
361
362 @pytest.mark.anyio
363 async def test_unstar_repo_idempotent(
364 client: AsyncClient,
365 db_session: AsyncSession,
366 auth_headers: dict[str, str],
367 ) -> None:
368 """Unstarring a repo that was never starred returns 200 with star_count=0."""
369 repo_id = await _make_public_repo(db_session)
370 response = await client.delete(
371 f"/api/v1/musehub/repos/{repo_id}/star", headers=auth_headers
372 )
373 assert response.status_code == 200
374 assert response.json()["starCount"] == 0
375
376
377 @pytest.mark.anyio
378 async def test_star_private_repo_returns_404(
379 client: AsyncClient,
380 db_session: AsyncSession,
381 auth_headers: dict[str, str],
382 ) -> None:
383 """POST /star on a private repo returns 404 — private repos cannot be starred."""
384 repo_id = await _make_private_repo(db_session)
385 response = await client.post(
386 f"/api/v1/musehub/repos/{repo_id}/star", headers=auth_headers
387 )
388 assert response.status_code == 404