gabriel / musehub public
test_musehub_discover.py python
293 lines 10.9 KB
Raw
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b Merge branch 'fix/wire-push-external-parent-manifest' into dev Human 9 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_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
File History 15 commits
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b Merge branch 'fix/wire-push-external-parent-manifest' into dev Human 9 days ago
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 9 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454 chore: bump version to 0.2.0.dev2 — nightly.2, matching muse Sonnet 4.6 patch 12 days ago
sha256:31491a5f31832312965ba9c8d73c9eb6171069015bde3a471acc9d5006c1e45a revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 15 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 15 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 18 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 30 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 34 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 35 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 36 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 36 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 49 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 51 days ago