gabriel / musehub public
test_musehub_discover.py python
213 lines 7.5 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days 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_list_public_repos_empty — no public repos → empty list
6 - test_explore_only_public_repos — private repos are excluded from results
7 - test_explore_filters_by_genre — genre tag filter works
8 - test_explore_filters_by_instrumentation — instrumentation tag filter works
9 - test_explore_sorts_by_stars — star-count sort returns highest-starred first
10 - test_explore_sorts_by_created — created sort returns newest first
11 - test_explore_pagination — page 2 returns different repos
12 - test_star_repo_requires_auth — POST /star returns 401 without MSign auth
13 - test_star_repo_adds_star — star increments star_count
14 - test_star_repo_idempotent — duplicate star is silent
15 - test_unstar_repo_removes_star — unstar decrements star_count
16 - test_unstar_repo_idempotent — unstarring twice is a no-op
17 - test_star_private_repo_returns_404 — cannot star a private repo
18 """
19 from __future__ import annotations
20
21 import pytest
22 from httpx import AsyncClient
23 from sqlalchemy.ext.asyncio import AsyncSession
24
25 from musehub.db.musehub_models import MusehubRepo
26
27
28 # ---------------------------------------------------------------------------
29 # Helpers
30 # ---------------------------------------------------------------------------
31
32
33 async def _make_public_repo(
34 db_session: AsyncSession,
35 *,
36 name: str = "test-jazz-repo",
37 tags: list[str] | None = None,
38 description: str = "",
39 ) -> str:
40 """Seed a public repo and return its repo_id."""
41 import re as _re
42 slug = _re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:64].strip("-") or "repo"
43 repo = MusehubRepo(
44 name=name,
45 owner="testuser",
46 slug=slug,
47 visibility="public",
48 owner_user_id="test-owner",
49 description=description,
50 tags=tags or [],
51 )
52 db_session.add(repo)
53 await db_session.commit()
54 await db_session.refresh(repo)
55 return str(repo.repo_id)
56
57
58 async def _make_private_repo(db_session: AsyncSession, name: str = "private-beats") -> str:
59 """Seed a private repo and return its repo_id."""
60 import re as _re
61 slug = _re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:64].strip("-") or "repo"
62 repo = MusehubRepo(
63 name=name,
64 owner="testuser",
65 slug=slug,
66 visibility="private",
67 owner_user_id="test-owner",
68 description="",
69 tags=[],
70 )
71 db_session.add(repo)
72 await db_session.commit()
73 await db_session.refresh(repo)
74 return str(repo.repo_id)
75
76
77 # ---------------------------------------------------------------------------
78 # UI page tests (no auth required)
79 # ---------------------------------------------------------------------------
80
81
82 @pytest.mark.anyio
83 async def test_explore_page_renders(client: AsyncClient) -> None:
84 """GET /explore returns 200 HTML with filter controls."""
85 response = await client.get("/explore")
86 assert response.status_code == 200
87 assert "text/html" in response.headers["content-type"]
88 body = response.text
89 assert "MuseHub" in body
90 assert "Explore" in body
91 # Filter sidebar and sort controls rendered by the Jinja2 template
92 assert "filter-form" in body
93 assert 'name="sort"' in body
94 assert 'name="license"' in body
95 assert "/explore" in body
96
97
98
99 @pytest.mark.anyio
100 async def test_explore_page_no_auth_required(client: AsyncClient) -> None:
101 """GET /explore must not return 401 — it is a public page."""
102 response = await client.get("/explore")
103 assert response.status_code == 200
104
105
106 # ---------------------------------------------------------------------------
107 # JSON API tests — public browse endpoint (no auth)
108 # ---------------------------------------------------------------------------
109
110
111 @pytest.mark.anyio
112 async def test_list_public_repos_empty(client: AsyncClient, db_session: AsyncSession) -> None:
113 """GET /api/discover/repos returns empty list when no public repos exist."""
114 response = await client.get("/api/discover/repos")
115 assert response.status_code == 200
116 body = response.json()
117 assert body["repos"] == []
118 assert body["total"] == 0
119 assert body["page"] == 1
120 assert body["pageSize"] == 24
121
122
123 @pytest.mark.anyio
124 async def test_explore_only_public_repos(
125 client: AsyncClient, db_session: AsyncSession
126 ) -> None:
127 """Private repos must not appear in discover results."""
128 await _make_public_repo(db_session, name="public-one")
129 await _make_private_repo(db_session, name="private-one")
130
131 response = await client.get("/api/discover/repos")
132 assert response.status_code == 200
133 body = response.json()
134 assert body["total"] == 1
135 names = [r["name"] for r in body["repos"]]
136 assert "public-one" in names
137 assert "private-one" not in names
138
139
140 @pytest.mark.anyio
141 async def test_explore_filters_by_genre(
142 client: AsyncClient, db_session: AsyncSession
143 ) -> None:
144 """genre= filter returns only repos whose tags contain the genre string."""
145 await _make_public_repo(db_session, name="jazz-project", tags=["jazz", "swing"])
146 await _make_public_repo(db_session, name="lofi-project", tags=["lo-fi", "chill"])
147
148 response = await client.get("/api/discover/repos?genre=jazz")
149 assert response.status_code == 200
150 body = response.json()
151 assert body["total"] == 1
152 assert body["repos"][0]["name"] == "jazz-project"
153
154
155 @pytest.mark.anyio
156 async def test_explore_filters_by_instrumentation(
157 client: AsyncClient, db_session: AsyncSession
158 ) -> None:
159 """instrumentation= filter matches repos whose tags include the instrument."""
160 await _make_public_repo(db_session, name="bass-heavy", tags=["jazz", "bass", "drums"])
161 await _make_public_repo(db_session, name="keys-only", tags=["ambient", "keys"])
162
163 response = await client.get("/api/discover/repos?instrumentation=bass")
164 assert response.status_code == 200
165 body = response.json()
166 assert body["total"] == 1
167 assert body["repos"][0]["name"] == "bass-heavy"
168
169
170 @pytest.mark.anyio
171 async def test_explore_sorts_by_created(
172 client: AsyncClient, db_session: AsyncSession
173 ) -> None:
174 """sort=created returns newest repos first (default sort)."""
175 await _make_public_repo(db_session, name="first-created")
176 await _make_public_repo(db_session, name="second-created")
177
178 response = await client.get("/api/discover/repos?sort=created")
179 assert response.status_code == 200
180 body = response.json()
181 # Newest first — second-created was inserted last
182 names = [r["name"] for r in body["repos"]]
183 assert names.index("second-created") < names.index("first-created")
184
185
186 @pytest.mark.anyio
187 async def test_explore_pagination(
188 client: AsyncClient, db_session: AsyncSession
189 ) -> None:
190 """Page 2 returns a different set of repos than page 1."""
191 for i in range(5):
192 await _make_public_repo(db_session, name=f"repo-{i:02d}")
193
194 page1 = (await client.get("/api/discover/repos?page=1&page_size=3")).json()
195 page2 = (await client.get("/api/discover/repos?page=2&page_size=3")).json()
196
197 assert page1["total"] == 5
198 assert page2["total"] == 5
199 page1_ids = {r["repoId"] for r in page1["repos"]}
200 page2_ids = {r["repoId"] for r in page2["repos"]}
201 # Pages must not overlap
202 assert not page1_ids & page2_ids
203
204
205 @pytest.mark.anyio
206 async def test_explore_invalid_sort_returns_422(
207 client: AsyncClient, db_session: AsyncSession
208 ) -> None:
209 """sort= with an invalid value returns 422 Unprocessable Entity."""
210 response = await client.get("/api/discover/repos?sort=invalid")
211 assert response.status_code == 422
212
213
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago