gabriel / musehub public
test_musehub_discover.py python
386 lines 13.6 KB
c0f0b481 release: merge dev → main (#5) Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """Tests for the MuseHub explore/discover API endpoints.
2
3 Covers acceptance criteria:
4 - test_explore_page_renders — GET /explore returns 200 HTML
5 - test_trending_page_renders — GET /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 /explore returns 200 HTML with filter controls."""
92 response = await client.get("/explore")
93 assert response.status_code == 200
94 assert "text/html" in response.headers["content-type"]
95 body = response.text
96 assert "MuseHub" in body
97 assert "Explore" in body
98 # Filter sidebar and sort controls rendered by the Jinja2 template
99 assert "filter-form" in body
100 assert 'name="sort"' in body
101 assert 'name="license"' in body
102 assert "/explore" in body
103
104
105 @pytest.mark.anyio
106 async def test_trending_page_renders(client: AsyncClient) -> None:
107 """GET /trending returns 200 HTML with repo grid."""
108 response = await client.get("/trending")
109 assert response.status_code == 200
110 assert "text/html" in response.headers["content-type"]
111 body = response.text
112 assert "MuseHub" in body
113 assert "Trending" in body
114 # Repo grid container rendered by the Jinja2 template
115 assert "repo-grid" in body
116 assert "Trending Music" in body
117
118
119 @pytest.mark.anyio
120 async def test_explore_page_no_auth_required(client: AsyncClient) -> None:
121 """GET /explore must not return 401 — it is a public page."""
122 response = await client.get("/explore")
123 assert response.status_code == 200
124
125
126 # ---------------------------------------------------------------------------
127 # JSON API tests — public browse endpoint (no auth)
128 # ---------------------------------------------------------------------------
129
130
131 @pytest.mark.anyio
132 async def test_list_public_repos_empty(client: AsyncClient, db_session: AsyncSession) -> None:
133 """GET /api/v1/discover/repos returns empty list when no public repos exist."""
134 response = await client.get("/api/v1/discover/repos")
135 assert response.status_code == 200
136 body = response.json()
137 assert body["repos"] == []
138 assert body["total"] == 0
139 assert body["page"] == 1
140 assert body["pageSize"] == 24
141
142
143 @pytest.mark.anyio
144 async def test_explore_only_public_repos(
145 client: AsyncClient, db_session: AsyncSession
146 ) -> None:
147 """Private repos must not appear in discover results."""
148 await _make_public_repo(db_session, name="public-one")
149 await _make_private_repo(db_session, name="private-one")
150
151 response = await client.get("/api/v1/discover/repos")
152 assert response.status_code == 200
153 body = response.json()
154 assert body["total"] == 1
155 names = [r["name"] for r in body["repos"]]
156 assert "public-one" in names
157 assert "private-one" not in names
158
159
160 @pytest.mark.anyio
161 async def test_explore_filters_by_genre(
162 client: AsyncClient, db_session: AsyncSession
163 ) -> None:
164 """genre= filter returns only repos whose tags contain the genre string."""
165 await _make_public_repo(db_session, name="jazz-project", tags=["jazz", "swing"])
166 await _make_public_repo(db_session, name="lofi-project", tags=["lo-fi", "chill"])
167
168 response = await client.get("/api/v1/discover/repos?genre=jazz")
169 assert response.status_code == 200
170 body = response.json()
171 assert body["total"] == 1
172 assert body["repos"][0]["name"] == "jazz-project"
173
174
175 @pytest.mark.anyio
176 async def test_explore_filters_by_key(
177 client: AsyncClient, db_session: AsyncSession
178 ) -> None:
179 """key= filter returns only repos with the matching key_signature."""
180 await _make_public_repo(db_session, name="fsharp-minor", key_signature="F# minor")
181 await _make_public_repo(db_session, name="c-major", key_signature="C major")
182
183 response = await client.get("/api/v1/discover/repos?key=F%23+minor")
184 assert response.status_code == 200
185 body = response.json()
186 assert body["total"] == 1
187 assert body["repos"][0]["name"] == "fsharp-minor"
188
189
190 @pytest.mark.anyio
191 async def test_explore_filters_by_tempo(
192 client: AsyncClient, db_session: AsyncSession
193 ) -> None:
194 """tempo_min and tempo_max filter repos by BPM range."""
195 await _make_public_repo(db_session, name="slow", tempo_bpm=70)
196 await _make_public_repo(db_session, name="mid", tempo_bpm=100)
197 await _make_public_repo(db_session, name="fast", tempo_bpm=150)
198
199 response = await client.get("/api/v1/discover/repos?tempo_min=90&tempo_max=120")
200 assert response.status_code == 200
201 body = response.json()
202 assert body["total"] == 1
203 assert body["repos"][0]["name"] == "mid"
204
205
206 @pytest.mark.anyio
207 async def test_explore_filters_by_instrumentation(
208 client: AsyncClient, db_session: AsyncSession
209 ) -> None:
210 """instrumentation= filter matches repos whose tags include the instrument."""
211 await _make_public_repo(db_session, name="bass-heavy", tags=["jazz", "bass", "drums"])
212 await _make_public_repo(db_session, name="keys-only", tags=["ambient", "keys"])
213
214 response = await client.get("/api/v1/discover/repos?instrumentation=bass")
215 assert response.status_code == 200
216 body = response.json()
217 assert body["total"] == 1
218 assert body["repos"][0]["name"] == "bass-heavy"
219
220
221 @pytest.mark.anyio
222 async def test_explore_sorts_by_stars(
223 client: AsyncClient, db_session: AsyncSession, auth_headers: dict[str, str]
224 ) -> None:
225 """sort=stars returns the repo with more stars first."""
226 repo_a = await _make_public_repo(db_session, name="repo-a")
227 repo_b = await _make_public_repo(db_session, name="repo-b")
228
229 # Star repo_b twice (from different users) so it has more stars
230 star1 = MusehubStar(repo_id=repo_b, user_id="user-1")
231 star2 = MusehubStar(repo_id=repo_b, user_id="user-2")
232 star3 = MusehubStar(repo_id=repo_a, user_id="user-3")
233 db_session.add_all([star1, star2, star3])
234 await db_session.commit()
235
236 response = await client.get("/api/v1/discover/repos?sort=stars")
237 assert response.status_code == 200
238 body = response.json()
239 repos = body["repos"]
240 assert len(repos) == 2
241 # repo-b has 2 stars; must come first
242 assert repos[0]["name"] == "repo-b"
243 assert repos[0]["starCount"] == 2
244 assert repos[1]["name"] == "repo-a"
245 assert repos[1]["starCount"] == 1
246
247
248 @pytest.mark.anyio
249 async def test_explore_sorts_by_created(
250 client: AsyncClient, db_session: AsyncSession
251 ) -> None:
252 """sort=created returns newest repos first (default sort)."""
253 await _make_public_repo(db_session, name="first-created")
254 await _make_public_repo(db_session, name="second-created")
255
256 response = await client.get("/api/v1/discover/repos?sort=created")
257 assert response.status_code == 200
258 body = response.json()
259 # Newest first — second-created was inserted last
260 names = [r["name"] for r in body["repos"]]
261 assert names.index("second-created") < names.index("first-created")
262
263
264 @pytest.mark.anyio
265 async def test_explore_pagination(
266 client: AsyncClient, db_session: AsyncSession
267 ) -> None:
268 """Page 2 returns a different set of repos than page 1."""
269 for i in range(5):
270 await _make_public_repo(db_session, name=f"repo-{i:02d}")
271
272 page1 = (await client.get("/api/v1/discover/repos?page=1&page_size=3")).json()
273 page2 = (await client.get("/api/v1/discover/repos?page=2&page_size=3")).json()
274
275 assert page1["total"] == 5
276 assert page2["total"] == 5
277 page1_ids = {r["repoId"] for r in page1["repos"]}
278 page2_ids = {r["repoId"] for r in page2["repos"]}
279 # Pages must not overlap
280 assert not page1_ids & page2_ids
281
282
283 @pytest.mark.anyio
284 async def test_explore_invalid_sort_returns_422(
285 client: AsyncClient, db_session: AsyncSession
286 ) -> None:
287 """sort= with an invalid value returns 422 Unprocessable Entity."""
288 response = await client.get("/api/v1/discover/repos?sort=invalid")
289 assert response.status_code == 422
290
291
292 # ---------------------------------------------------------------------------
293 # Star / unstar tests (auth required)
294 # ---------------------------------------------------------------------------
295
296
297 @pytest.mark.anyio
298 async def test_star_repo_requires_auth(
299 client: AsyncClient, db_session: AsyncSession
300 ) -> None:
301 """POST /api/v1/repos/{repo_id}/star returns 401 without a JWT."""
302 repo_id = await _make_public_repo(db_session)
303 response = await client.post(f"/api/v1/repos/{repo_id}/star")
304 assert response.status_code == 401
305
306
307 @pytest.mark.anyio
308 async def test_star_repo_adds_star(
309 client: AsyncClient,
310 db_session: AsyncSession,
311 auth_headers: dict[str, str],
312 ) -> None:
313 """POST /api/v1/repos/{repo_id}/star returns starred=True and correct count."""
314 repo_id = await _make_public_repo(db_session)
315 response = await client.post(
316 f"/api/v1/repos/{repo_id}/star",
317 headers=auth_headers,
318 )
319 assert response.status_code == 200
320 body = response.json()
321 assert body["starred"] is True
322 assert body["starCount"] == 1
323
324
325 @pytest.mark.anyio
326 async def test_star_repo_idempotent(
327 client: AsyncClient,
328 db_session: AsyncSession,
329 auth_headers: dict[str, str],
330 ) -> None:
331 """Starring the same repo twice does not create duplicate stars."""
332 repo_id = await _make_public_repo(db_session)
333 await client.post(f"/api/v1/repos/{repo_id}/star", headers=auth_headers)
334 response = await client.post(
335 f"/api/v1/repos/{repo_id}/star", headers=auth_headers
336 )
337 assert response.status_code == 200
338 assert response.json()["starCount"] == 1
339
340
341 @pytest.mark.anyio
342 async def test_unstar_repo_removes_star(
343 client: AsyncClient,
344 db_session: AsyncSession,
345 auth_headers: dict[str, str],
346 ) -> None:
347 """DELETE .../star after starring reduces star_count to 0."""
348 repo_id = await _make_public_repo(db_session)
349 await client.post(f"/api/v1/repos/{repo_id}/star", headers=auth_headers)
350
351 response = await client.delete(
352 f"/api/v1/repos/{repo_id}/star", headers=auth_headers
353 )
354 assert response.status_code == 200
355 body = response.json()
356 assert body["starred"] is False
357 assert body["starCount"] == 0
358
359
360 @pytest.mark.anyio
361 async def test_unstar_repo_idempotent(
362 client: AsyncClient,
363 db_session: AsyncSession,
364 auth_headers: dict[str, str],
365 ) -> None:
366 """Unstarring a repo that was never starred returns 200 with star_count=0."""
367 repo_id = await _make_public_repo(db_session)
368 response = await client.delete(
369 f"/api/v1/repos/{repo_id}/star", headers=auth_headers
370 )
371 assert response.status_code == 200
372 assert response.json()["starCount"] == 0
373
374
375 @pytest.mark.anyio
376 async def test_star_private_repo_returns_404(
377 client: AsyncClient,
378 db_session: AsyncSession,
379 auth_headers: dict[str, str],
380 ) -> None:
381 """POST /star on a private repo returns 404 — private repos cannot be starred."""
382 repo_id = await _make_private_repo(db_session)
383 response = await client.post(
384 f"/api/v1/repos/{repo_id}/star", headers=auth_headers
385 )
386 assert response.status_code == 404