gabriel / musehub public

test_musehub_discover.py file-level

at sha256:d · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:e fix(9A-4 F7): env alias + staging secrets for overseer provenance gate.… · aaronrene · Jul 13, 2026
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_created β€” created sort returns newest first
10 - test_explore_pagination β€” page 2 returns different repos
11 """
12 from __future__ import annotations
13
14 from datetime import datetime, timezone
15
16 import pytest
17 from httpx import AsyncClient
18 from sqlalchemy.ext.asyncio import AsyncSession
19
20 from musehub.core.genesis import compute_identity_id, compute_repo_id
21 from musehub.db.musehub_repo_models import MusehubRepo
22
23
24 # ---------------------------------------------------------------------------
25 # Helpers
26 # ---------------------------------------------------------------------------
27
28
29 async def _make_public_repo(
30 db_session: AsyncSession,
31 *,
32 name: str = "test-jazz-repo",
33 tags: list[str] | None = None,
34 description: str = "",
35 domain: str = "code",
36 ) -> str:
37 """Seed a public repo and return its repo_id."""
38 import re as _re
39 slug = _re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:64].strip("-") or "repo"
40 created_at = datetime.now(tz=timezone.utc)
41 owner_id = compute_identity_id(b"testuser")
42 repo = MusehubRepo(
43 repo_id=compute_repo_id(owner_id, slug, domain, created_at.isoformat()),
44 name=name,
45 owner="testuser",
46 slug=slug,
47 visibility="public",
48 owner_user_id=owner_id,
49 domain_id=domain,
50 description=description,
51 tags=tags or [],
52 created_at=created_at,
53 updated_at=created_at,
54 )
55 db_session.add(repo)
56 await db_session.commit()
57 await db_session.refresh(repo)
58 return str(repo.repo_id)
59
60
61 async def _make_private_repo(db_session: AsyncSession, name: str = "private-beats") -> str:
62 """Seed a private repo and return its repo_id."""
63 import re as _re
64 slug = _re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:64].strip("-") or "repo"
65 created_at = datetime.now(tz=timezone.utc)
66 owner_id = compute_identity_id(b"testuser")
67 repo = MusehubRepo(
68 repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()),
69 name=name,
70 owner="testuser",
71 slug=slug,
72 visibility="private",
73 owner_user_id=owner_id,
74 description="",
75 tags=[],
76 created_at=created_at,
77 updated_at=created_at,
78 )
79 db_session.add(repo)
80 await db_session.commit()
81 await db_session.refresh(repo)
82 return str(repo.repo_id)
83
84
85 # ---------------------------------------------------------------------------
86 # UI page tests (no auth required)
87 # ---------------------------------------------------------------------------
88
89
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
106 async def test_explore_page_no_auth_required(client: AsyncClient) -> None:
107 """GET /explore must not return 401 β€” it is a public page."""
108 response = await client.get("/explore")
109 assert response.status_code == 200
110
111
112 # ---------------------------------------------------------------------------
113 # JSON API tests β€” public browse endpoint (no auth)
114 # ---------------------------------------------------------------------------
115
116
117 async def test_list_public_repos_empty(client: AsyncClient, db_session: AsyncSession) -> None:
118 """GET /api/discover/repos returns empty list when no public repos exist."""
119 response = await client.get("/api/discover/repos")
120 assert response.status_code == 200
121 body = response.json()
122 assert body["repos"] == []
123 assert body["total"] == 0
124 assert body["nextCursor"] is None
125
126
127 async def test_explore_only_public_repos(
128 client: AsyncClient, db_session: AsyncSession
129 ) -> None:
130 """Private repos must not appear in discover results."""
131 await _make_public_repo(db_session, name="public-one")
132 await _make_private_repo(db_session, name="private-one")
133
134 response = await client.get("/api/discover/repos")
135 assert response.status_code == 200
136 body = response.json()
137 assert body["total"] == 1
138 names = [r["name"] for r in body["repos"]]
139 assert "public-one" in names
140 assert "private-one" not in names
141
142
143 # ---------------------------------------------------------------------------
144 # Mists must not appear in repo Explore β€” a mist is a Muse gist, backed by a
145 # real MusehubRepo row (domain_id="mist") purely as VCS plumbing, not a
146 # project a user is trying to showcase. See musehub#<mists-explore-filter>.
147 # ---------------------------------------------------------------------------
148
149
150 async def test_explore_excludes_mist_backed_repos(
151 client: AsyncClient, db_session: AsyncSession
152 ) -> None:
153 """A mist's backing repo (domain_id='mist') must not appear in /api/discover/repos."""
154 await _make_public_repo(db_session, name="real-project")
155 await _make_public_repo(db_session, name="mist-backing-repo", domain="mist")
156
157 response = await client.get("/api/discover/repos")
158 assert response.status_code == 200
159 body = response.json()
160 assert body["total"] == 1
161 names = [r["name"] for r in body["repos"]]
162 assert "real-project" in names
163 assert "mist-backing-repo" not in names
164
165
166 async def test_explore_page_excludes_mist_backed_repos(
167 client: AsyncClient, db_session: AsyncSession
168 ) -> None:
169 """The SSR /explore grid must not render a mist's backing repo."""
170 await _make_public_repo(db_session, name="visible-project")
171 await _make_public_repo(db_session, name="hidden-mist-repo", domain="mist")
172
173 response = await client.get("/explore")
174 assert response.status_code == 200
175 assert "visible-project" in response.text
176 assert "hidden-mist-repo" not in response.text
177
178
179 async def test_explore_search_excludes_mist_backed_repos(
180 db_session: AsyncSession,
181 ) -> None:
182 """search_repos_by_text (the /explore/search fragment's data source) must
183 not surface a mist's backing repo.
184
185 Tested at the service layer, not via HTTP: GET /explore/search has a
186 separate, pre-existing template-rendering bug (a 500 on any query with
187 real results, unrelated to mist filtering β€” reproduces in complete
188 isolation with zero mist repos involved) that blocks HTTP-level
189 assertions here. Flagged separately; out of scope for this fix.
190 """
191 from musehub.services.musehub_discover import search_repos_by_text
192
193 await _make_public_repo(db_session, name="findable-project")
194 await _make_public_repo(db_session, name="findable-mist-repo", domain="mist")
195
196 results = await search_repos_by_text(db_session, "findable", limit=20)
197 names = [r.name for r in results]
198 assert "findable-project" in names
199 assert "findable-mist-repo" not in names
200
201
202 async def test_explore_stats_exclude_mist_backed_repos(
203 client: AsyncClient, db_session: AsyncSession
204 ) -> None:
205 """Explore's sidebar tag/topic chips and hero stats must not count mist repos.
206
207 A mist-backed repo carries no real commits or genuine tags for the
208 project it's attached to β€” mixing its tags into the language/topic chip
209 clouds would surface noise (mist filenames, artifact-type tags) that has
210 nothing to do with real project discovery.
211 """
212 await _make_public_repo(db_session, name="tagged-project", tags=["genre:jazz"])
213 await _make_public_repo(
214 db_session, name="tagged-mist-repo", tags=["genre:should-not-appear"], domain="mist"
215 )
216
217 response = await client.get("/explore")
218 assert response.status_code == 200
219 assert "should-not-appear" not in response.text
220
221
222 async def test_explore_filters_by_genre(
223 client: AsyncClient, db_session: AsyncSession
224 ) -> None:
225 """genre= filter returns only repos whose tags contain the genre string."""
226 await _make_public_repo(db_session, name="jazz-project", tags=["jazz", "swing"])
227 await _make_public_repo(db_session, name="lofi-project", tags=["lo-fi", "chill"])
228
229 response = await client.get("/api/discover/repos?genre=jazz")
230 assert response.status_code == 200
231 body = response.json()
232 assert body["total"] == 1
233 assert body["repos"][0]["name"] == "jazz-project"
234
235
236 async def test_explore_filters_by_instrumentation(
237 client: AsyncClient, db_session: AsyncSession
238 ) -> None:
239 """instrumentation= filter matches repos whose tags include the instrument."""
240 await _make_public_repo(db_session, name="bass-heavy", tags=["jazz", "bass", "drums"])
241 await _make_public_repo(db_session, name="keys-only", tags=["ambient", "keys"])
242
243 response = await client.get("/api/discover/repos?instrumentation=bass")
244 assert response.status_code == 200
245 body = response.json()
246 assert body["total"] == 1
247 assert body["repos"][0]["name"] == "bass-heavy"
248
249
250 async def test_explore_sorts_by_created(
251 client: AsyncClient, db_session: AsyncSession
252 ) -> None:
253 """sort=created returns newest repos first (default sort)."""
254 await _make_public_repo(db_session, name="first-created")
255 await _make_public_repo(db_session, name="second-created")
256
257 response = await client.get("/api/discover/repos?sort=created")
258 assert response.status_code == 200
259 body = response.json()
260 # Newest first β€” second-created was inserted last
261 names = [r["name"] for r in body["repos"]]
262 assert names.index("second-created") < names.index("first-created")
263
264
265 async def test_explore_pagination(
266 client: AsyncClient, db_session: AsyncSession
267 ) -> None:
268 """Cursor pagination: second page 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/discover/repos?limit=3")).json()
273 assert page1["total"] == 5
274 assert page1["nextCursor"] is not None
275
276 next_cursor = page1["nextCursor"]
277 page2 = (await client.get(f"/api/discover/repos?limit=3&cursor={next_cursor}")).json()
278
279 assert page2["total"] == 5
280 page1_ids = {r["repoId"] for r in page1["repos"]}
281 page2_ids = {r["repoId"] for r in page2["repos"]}
282 # Pages must not overlap
283 assert not page1_ids & page2_ids
284
285
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/discover/repos?sort=invalid")
291 assert response.status_code == 422
292
293