gabriel / musehub public
test_musehub_sitemap.py python
312 lines 11.2 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 122 days ago
1 """Tests for the MuseHub sitemap.xml and robots.txt endpoints.
2
3 Covers acceptance criteria:
4 - test_sitemap_returns_xml — GET /sitemap.xml returns 200 with XML content-type
5 - test_sitemap_contains_static_pages — static explore/trending/topics URLs are always present
6 - test_sitemap_contains_public_repo — a seeded public repo appears in the sitemap
7 - test_sitemap_excludes_private_repo — private repos do NOT appear in the sitemap
8 - test_sitemap_contains_user_profile — seeded user profile URL appears in sitemap
9 - test_sitemap_contains_topic_urls — repo tags generate /topics/{tag} entries
10 - test_sitemap_contains_release_url — a release URL appears for repos with releases
11 - test_sitemap_xml_well_formed — sitemap can be parsed as valid XML
12 - test_sitemap_loc_uses_request_host — loc entries use the base URL from the request
13 - test_robots_txt_returns_plain_text — GET /robots.txt returns 200 text/plain
14 - test_robots_txt_allows_musehub_ui — Allow: / is present
15 - test_robots_txt_disallows_settings — settings path is disallowed
16 - test_robots_txt_disallows_api — /api/ directory is disallowed
17 - test_robots_txt_contains_sitemap_url — Sitemap: directive points to /sitemap.xml
18 - test_robots_txt_names_known_agents — known AI bots appear with explicit Allow
19 - test_robots_txt_no_auth_required — endpoint is accessible without authentication
20 - test_sitemap_no_auth_required — sitemap is accessible without authentication
21 """
22 from __future__ import annotations
23
24 import pytest
25 from httpx import AsyncClient
26 from sqlalchemy.ext.asyncio import AsyncSession
27 from xml.etree import ElementTree as ET
28
29 from musehub.core.genesis import compute_identity_id, compute_release_id, compute_repo_id
30 from musehub.db.musehub_models import (
31 MusehubIdentity,
32 MusehubRelease,
33 MusehubRepo,
34 )
35
36
37 # ---------------------------------------------------------------------------
38 # Helpers
39 # ---------------------------------------------------------------------------
40
41
42 async def _make_public_repo(
43 db_session: AsyncSession,
44 *,
45 owner: str = "sitemap-user",
46 slug: str = "sitemap-repo",
47 tags: list[str] | None = None,
48 visibility: str = "public",
49 ) -> MusehubRepo:
50 """Seed a repo and return the ORM object."""
51 from datetime import datetime, timezone
52 created_at = datetime.now(tz=timezone.utc)
53 owner_id = compute_identity_id(owner.encode())
54 repo = MusehubRepo(
55 repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()),
56 name=slug,
57 owner=owner,
58 slug=slug,
59 visibility=visibility,
60 owner_user_id=owner_id,
61 description="test repo for sitemap",
62 tags=tags or [],
63 created_at=created_at,
64 updated_at=created_at,
65 )
66 db_session.add(repo)
67 await db_session.commit()
68 await db_session.refresh(repo)
69 return repo
70
71
72 async def _make_profile(
73 db_session: AsyncSession,
74 *,
75 username: str = "sitemap-user",
76 user_id: str = "sitemap-user-id",
77 ) -> MusehubIdentity:
78 """Seed a user identity and return the ORM object."""
79 identity = MusehubIdentity(
80 identity_id=user_id,
81 handle=username,
82 identity_type="human",
83 )
84 db_session.add(identity)
85 await db_session.commit()
86 await db_session.refresh(identity)
87 return identity
88
89
90 async def _make_release(
91 db_session: AsyncSession,
92 repo_id: str,
93 *,
94 tag: str = "v1.0",
95 ) -> MusehubRelease:
96 """Seed a release and return the ORM object."""
97 from datetime import datetime, timezone
98 _ts = datetime.now(tz=timezone.utc)
99 release = MusehubRelease(
100 release_id=compute_release_id(repo_id, tag, _ts.isoformat()),
101 repo_id=repo_id,
102 tag=tag,
103 title=f"Release {tag}",
104 body="",
105 author="sitemap-user",
106 )
107 db_session.add(release)
108 await db_session.commit()
109 await db_session.refresh(release)
110 return release
111
112
113 # ---------------------------------------------------------------------------
114 # Sitemap tests
115 # ---------------------------------------------------------------------------
116
117
118 async def test_sitemap_returns_xml(client: AsyncClient, db_session: AsyncSession) -> None:
119 """GET /sitemap.xml returns 200 with an XML content-type."""
120 response = await client.get("/sitemap.xml")
121 assert response.status_code == 200
122 assert "xml" in response.headers["content-type"]
123
124
125 async def test_sitemap_contains_static_pages(
126 client: AsyncClient, db_session: AsyncSession
127 ) -> None:
128 """Static explore and topics pages are always included in the sitemap."""
129 response = await client.get("/sitemap.xml")
130 assert response.status_code == 200
131 body = response.text
132 assert "/explore" in body
133 assert "/topics" in body
134
135
136 async def test_sitemap_contains_public_repo(
137 client: AsyncClient, db_session: AsyncSession
138 ) -> None:
139 """A seeded public repo's UI URL appears in the sitemap."""
140 await _make_public_repo(db_session, owner="artist", slug="cool-track")
141 response = await client.get("/sitemap.xml")
142 assert response.status_code == 200
143 body = response.text
144 assert "/artist/cool-track" in body
145
146
147 async def test_sitemap_excludes_private_repo(
148 client: AsyncClient, db_session: AsyncSession
149 ) -> None:
150 """Private repos must not appear anywhere in the sitemap."""
151 await _make_public_repo(db_session, owner="secretuser", slug="hidden-project", visibility="private")
152 response = await client.get("/sitemap.xml")
153 assert response.status_code == 200
154 body = response.text
155 assert "hidden-project" not in body
156 assert "secretuser" not in body
157
158
159 async def test_sitemap_contains_user_profile(
160 client: AsyncClient, db_session: AsyncSession
161 ) -> None:
162 """A seeded user profile generates a /users/{username} entry."""
163 await _make_profile(db_session, username="jazzmaster", user_id="jazzmaster-uid")
164 response = await client.get("/sitemap.xml")
165 assert response.status_code == 200
166 assert "/users/jazzmaster" in response.text
167
168
169 async def test_sitemap_contains_topic_urls(
170 client: AsyncClient, db_session: AsyncSession
171 ) -> None:
172 """Tags on public repos generate /topics/{tag} entries."""
173 await _make_public_repo(db_session, owner="producer", slug="beats", tags=["lo-fi", "jazz"])
174 response = await client.get("/sitemap.xml")
175 assert response.status_code == 200
176 body = response.text
177 assert "/topics/lo-fi" in body
178 assert "/topics/jazz" in body
179
180
181 async def test_sitemap_contains_release_url(
182 client: AsyncClient, db_session: AsyncSession
183 ) -> None:
184 """A release on a public repo generates a /releases/{tag} sitemap entry."""
185 repo = await _make_public_repo(db_session, owner="bandname", slug="debut-album")
186 await _make_release(db_session, repo.repo_id, tag="v1.0")
187 response = await client.get("/sitemap.xml")
188 assert response.status_code == 200
189 assert "/bandname/debut-album/releases/v1.0" in response.text
190
191
192 async def test_sitemap_xml_well_formed(
193 client: AsyncClient, db_session: AsyncSession
194 ) -> None:
195 """The sitemap response must be parseable as valid XML."""
196 response = await client.get("/sitemap.xml")
197 assert response.status_code == 200
198 # This raises if the document is not well-formed XML.
199 root = ET.fromstring(response.content)
200 assert root.tag.endswith("urlset")
201
202
203 async def test_sitemap_loc_uses_request_host(
204 client: AsyncClient, db_session: AsyncSession
205 ) -> None:
206 """loc entries in the sitemap use the base URL from the incoming request."""
207 await _make_public_repo(db_session, owner="testowner", slug="testrepo")
208 response = await client.get("/sitemap.xml")
209 assert response.status_code == 200
210 # The test client uses base_url="http://test" — every loc must start with http://test.
211 body = response.text
212 assert "<loc>http://test" in body
213
214
215 async def test_sitemap_no_auth_required(
216 client: AsyncClient, db_session: AsyncSession
217 ) -> None:
218 """Sitemap endpoint must be accessible without authentication (crawlers don't authenticate)."""
219 response = await client.get("/sitemap.xml")
220 assert response.status_code != 401
221 assert response.status_code == 200
222
223
224 async def test_sitemap_repo_commits_page_included(
225 client: AsyncClient, db_session: AsyncSession
226 ) -> None:
227 """Each public repo's /commits page also appears in the sitemap."""
228 await _make_public_repo(db_session, owner="composer", slug="symphony-no1")
229 response = await client.get("/sitemap.xml")
230 assert response.status_code == 200
231 assert "/composer/symphony-no1/commits" in response.text
232
233
234 async def test_sitemap_repo_issues_page_included(
235 client: AsyncClient, db_session: AsyncSession
236 ) -> None:
237 """Each public repo's /issues page also appears in the sitemap."""
238 await _make_public_repo(db_session, owner="composer", slug="symphony-no2")
239 response = await client.get("/sitemap.xml")
240 assert response.status_code == 200
241 assert "/composer/symphony-no2/issues" in response.text
242
243
244 # ---------------------------------------------------------------------------
245 # Robots.txt tests
246 # ---------------------------------------------------------------------------
247
248
249 async def test_robots_txt_returns_plain_text(
250 client: AsyncClient, db_session: AsyncSession
251 ) -> None:
252 """GET /robots.txt returns 200 with text/plain content-type."""
253 response = await client.get("/robots.txt")
254 assert response.status_code == 200
255 assert "text/plain" in response.headers["content-type"]
256
257
258 async def test_robots_txt_allows_musehub_ui(
259 client: AsyncClient, db_session: AsyncSession
260 ) -> None:
261 """Allow: / is present for all crawlers."""
262 response = await client.get("/robots.txt")
263 assert response.status_code == 200
264 assert "Allow: /" in response.text
265
266
267 async def test_robots_txt_disallows_settings(
268 client: AsyncClient, db_session: AsyncSession
269 ) -> None:
270 """Settings paths are disallowed to prevent indexing of private user config pages."""
271 response = await client.get("/robots.txt")
272 assert response.status_code == 200
273 assert "Disallow: /*/settings" in response.text
274
275
276 async def test_robots_txt_disallows_api(
277 client: AsyncClient, db_session: AsyncSession
278 ) -> None:
279 """API paths are disallowed — crawlers should use the sitemap, not the REST API."""
280 response = await client.get("/robots.txt")
281 assert response.status_code == 200
282 assert "Disallow: /api/" in response.text
283
284
285 async def test_robots_txt_contains_sitemap_url(
286 client: AsyncClient, db_session: AsyncSession
287 ) -> None:
288 """Sitemap: directive is present and points to /sitemap.xml."""
289 response = await client.get("/robots.txt")
290 assert response.status_code == 200
291 assert "Sitemap:" in response.text
292 assert "sitemap.xml" in response.text
293
294
295 async def test_robots_txt_names_known_agents(
296 client: AsyncClient, db_session: AsyncSession
297 ) -> None:
298 """Known AI discovery bots (GPTBot, ClaudeBot, etc.) appear with explicit Allow."""
299 response = await client.get("/robots.txt")
300 assert response.status_code == 200
301 body = response.text
302 for bot in ("GPTBot", "ClaudeBot", "Googlebot", "CursorBot"):
303 assert bot in body
304
305
306 async def test_robots_txt_no_auth_required(
307 client: AsyncClient, db_session: AsyncSession
308 ) -> None:
309 """robots.txt must be accessible without authentication."""
310 response = await client.get("/robots.txt")
311 assert response.status_code != 401
312 assert response.status_code == 200
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 122 days ago