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