gabriel / musehub public
test_musehub_feeds.py python
606 lines 21.1 KB
c0f0b481 release: merge dev → main (#5) Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """Tests for MuseHub RSS/Atom feed endpoints.
2
3 Covers every acceptance criterion:
4 - GET /repos/{repo_id}/feed.rss — RSS 2.0 commit feed
5 - GET /repos/{repo_id}/releases.rss — RSS 2.0 releases feed
6 - GET /repos/{repo_id}/issues.rss — RSS 2.0 open-issues feed
7 - GET /repos/{repo_id}/feed.atom — Atom 1.0 commit feed
8 - Public repos return 200 with correct Content-Type
9 - Private repos return 403 (feed readers cannot supply credentials)
10 - Non-existent repos return 404
11 - Feed XML includes valid structure (channel/item for RSS, feed/entry for Atom)
12
13 All tests use the shared ``client``, ``auth_headers``, and ``db_session``
14 fixtures from conftest.py.
15 """
16 from __future__ import annotations
17
18 from datetime import datetime, timezone
19
20 import pytest
21 from httpx import AsyncClient
22 from sqlalchemy.ext.asyncio import AsyncSession
23
24 from musehub.api.routes.musehub.feeds import (
25 _atom_date,
26 _build_atom_envelope,
27 _build_rss_envelope,
28 _commit_atom_entry,
29 _commit_rss_item,
30 _issue_rss_item,
31 _release_rss_item,
32 _rss_pub_date,
33 )
34 from musehub.db.musehub_models import MusehubCommit
35 from musehub.models.musehub import (
36 CommitResponse,
37 IssueResponse,
38 ReleaseResponse,
39 )
40 from musehub.services.musehub_release_packager import build_empty_download_urls
41
42
43 # ---------------------------------------------------------------------------
44 # Helpers
45 # ---------------------------------------------------------------------------
46
47
48 async def _create_public_repo(
49 client: AsyncClient,
50 auth_headers: dict[str, str],
51 name: str = "feed-public-repo",
52 ) -> str:
53 """Create a public repo via the API and return its repo_id."""
54 response = await client.post(
55 "/api/v1/repos",
56 json={"name": name, "owner": "testuser", "visibility": "public"},
57 headers=auth_headers,
58 )
59 assert response.status_code == 201, response.text
60 repo_id: str = response.json()["repoId"]
61 return repo_id
62
63
64 async def _create_private_repo(
65 client: AsyncClient,
66 auth_headers: dict[str, str],
67 name: str = "feed-private-repo",
68 ) -> str:
69 """Create a private repo via the API and return its repo_id."""
70 response = await client.post(
71 "/api/v1/repos",
72 json={"name": name, "owner": "testuser", "visibility": "private"},
73 headers=auth_headers,
74 )
75 assert response.status_code == 201, response.text
76 repo_id: str = response.json()["repoId"]
77 return repo_id
78
79
80 async def _insert_commit(
81 db_session: AsyncSession,
82 repo_id: str,
83 commit_id: str,
84 message: str,
85 ) -> None:
86 """Insert a commit directly into the DB (no push API exists)."""
87 db_session.add(
88 MusehubCommit(
89 commit_id=commit_id,
90 repo_id=repo_id,
91 branch="main",
92 parent_ids=[],
93 message=message,
94 author="testuser",
95 timestamp=datetime.now(tz=timezone.utc),
96 )
97 )
98 await db_session.commit()
99
100
101 async def _create_release(
102 client: AsyncClient,
103 auth_headers: dict[str, str],
104 repo_id: str,
105 tag: str = "v1.0",
106 title: str = "Initial Release",
107 ) -> None:
108 """Create a release via the API."""
109 response = await client.post(
110 f"/api/v1/repos/{repo_id}/releases",
111 json={"tag": tag, "title": title, "body": "## Notes\n\nFirst release."},
112 headers=auth_headers,
113 )
114 assert response.status_code == 201, response.text
115
116
117 async def _create_issue(
118 client: AsyncClient,
119 auth_headers: dict[str, str],
120 repo_id: str,
121 title: str = "Verse feels unresolved",
122 ) -> None:
123 """Create an open issue via the API."""
124 response = await client.post(
125 f"/api/v1/repos/{repo_id}/issues",
126 json={"title": title, "body": "Needs work.", "labels": []},
127 headers=auth_headers,
128 )
129 assert response.status_code == 201, response.text
130
131
132 # ---------------------------------------------------------------------------
133 # GET /repos/{repo_id}/feed.rss — commit RSS feed
134 # ---------------------------------------------------------------------------
135
136
137 @pytest.mark.anyio
138 async def test_commit_rss_feed_public_repo_200(
139 client: AsyncClient,
140 auth_headers: dict[str, str],
141 ) -> None:
142 """Public repo commit RSS feed returns 200 with application/rss+xml."""
143 repo_id = await _create_public_repo(client, auth_headers, "rss-commit-public-1")
144 response = await client.get(f"/api/v1/repos/{repo_id}/feed.rss")
145 assert response.status_code == 200
146 assert "application/rss+xml" in response.headers["content-type"]
147
148
149 @pytest.mark.anyio
150 async def test_commit_rss_feed_contains_rss_structure(
151 client: AsyncClient,
152 auth_headers: dict[str, str],
153 ) -> None:
154 """Commit RSS feed body is valid RSS 2.0 with <rss> and <channel> tags."""
155 repo_id = await _create_public_repo(client, auth_headers, "rss-commit-structure")
156 response = await client.get(f"/api/v1/repos/{repo_id}/feed.rss")
157 assert response.status_code == 200
158 body = response.text
159 assert '<rss version="2.0">' in body
160 assert "<channel>" in body
161 assert "</channel>" in body
162 assert "</rss>" in body
163
164
165 @pytest.mark.anyio
166 async def test_commit_rss_feed_includes_commit_items(
167 client: AsyncClient,
168 auth_headers: dict[str, str],
169 db_session: AsyncSession,
170 ) -> None:
171 """Commit RSS feed contains <item> elements for each commit."""
172 repo_id = await _create_public_repo(client, auth_headers, "rss-commit-items")
173 await _insert_commit(db_session, repo_id, "cmt001", "Add melodic intro")
174 await _insert_commit(db_session, repo_id, "cmt002", "Rework verse harmony")
175
176 response = await client.get(f"/api/v1/repos/{repo_id}/feed.rss")
177 assert response.status_code == 200
178 body = response.text
179 assert "<item>" in body
180 assert "Add melodic intro" in body
181 assert "Rework verse harmony" in body
182
183
184 @pytest.mark.anyio
185 async def test_commit_rss_feed_private_repo_returns_403(
186 client: AsyncClient,
187 auth_headers: dict[str, str],
188 ) -> None:
189 """Private repo commit RSS feed returns 403 Forbidden."""
190 repo_id = await _create_private_repo(client, auth_headers, "rss-commit-private")
191 response = await client.get(f"/api/v1/repos/{repo_id}/feed.rss")
192 assert response.status_code == 403
193
194
195 @pytest.mark.anyio
196 async def test_commit_rss_feed_nonexistent_repo_returns_404(client: AsyncClient) -> None:
197 """Commit RSS feed returns 404 for a non-existent repo."""
198 response = await client.get("/api/v1/repos/ghost-repo-id/feed.rss")
199 assert response.status_code == 404
200
201
202 @pytest.mark.anyio
203 async def test_commit_rss_feed_empty_repo_returns_valid_xml(
204 client: AsyncClient,
205 auth_headers: dict[str, str],
206 ) -> None:
207 """Commit RSS feed is valid XML for a repo with only the auto-created initial commit.
208
209 Repo creation always inserts an initial commit, so the feed always contains
210 at least one <item>. This test verifies the XML envelope is well-formed.
211 """
212 repo_id = await _create_public_repo(client, auth_headers, "rss-commit-empty")
213 response = await client.get(f"/api/v1/repos/{repo_id}/feed.rss")
214 assert response.status_code == 200
215 body = response.text
216 assert '<rss version="2.0">' in body
217 assert "<channel>" in body
218 assert "</channel>" in body
219 assert "</rss>" in body
220
221
222 # ---------------------------------------------------------------------------
223 # GET /repos/{repo_id}/releases.rss — releases RSS feed
224 # ---------------------------------------------------------------------------
225
226
227 @pytest.mark.anyio
228 async def test_releases_rss_feed_public_repo_200(
229 client: AsyncClient,
230 auth_headers: dict[str, str],
231 ) -> None:
232 """Public repo releases RSS feed returns 200 with application/rss+xml."""
233 repo_id = await _create_public_repo(client, auth_headers, "rss-releases-public-1")
234 response = await client.get(f"/api/v1/repos/{repo_id}/releases.rss")
235 assert response.status_code == 200
236 assert "application/rss+xml" in response.headers["content-type"]
237
238
239 @pytest.mark.anyio
240 async def test_releases_rss_feed_includes_release_items(
241 client: AsyncClient,
242 auth_headers: dict[str, str],
243 ) -> None:
244 """Releases RSS feed includes <item> elements for each release."""
245 repo_id = await _create_public_repo(client, auth_headers, "rss-releases-items")
246 await _create_release(client, auth_headers, repo_id, tag="v1.0", title="First Cut")
247 await _create_release(client, auth_headers, repo_id, tag="v2.0", title="Second Cut")
248
249 response = await client.get(f"/api/v1/repos/{repo_id}/releases.rss")
250 assert response.status_code == 200
251 body = response.text
252 assert "<item>" in body
253 assert "Release v1.0: First Cut" in body
254 assert "Release v2.0: Second Cut" in body
255
256
257 @pytest.mark.anyio
258 async def test_releases_rss_feed_private_repo_returns_403(
259 client: AsyncClient,
260 auth_headers: dict[str, str],
261 ) -> None:
262 """Private repo releases RSS feed returns 403 Forbidden."""
263 repo_id = await _create_private_repo(client, auth_headers, "rss-releases-private")
264 response = await client.get(f"/api/v1/repos/{repo_id}/releases.rss")
265 assert response.status_code == 403
266
267
268 @pytest.mark.anyio
269 async def test_releases_rss_feed_nonexistent_repo_returns_404(client: AsyncClient) -> None:
270 """Releases RSS feed returns 404 for a non-existent repo."""
271 response = await client.get("/api/v1/repos/ghost-repo-id/releases.rss")
272 assert response.status_code == 404
273
274
275 # ---------------------------------------------------------------------------
276 # GET /repos/{repo_id}/issues.rss — open issues RSS feed
277 # ---------------------------------------------------------------------------
278
279
280 @pytest.mark.anyio
281 async def test_issues_rss_feed_public_repo_200(
282 client: AsyncClient,
283 auth_headers: dict[str, str],
284 ) -> None:
285 """Public repo issues RSS feed returns 200 with application/rss+xml."""
286 repo_id = await _create_public_repo(client, auth_headers, "rss-issues-public-1")
287 response = await client.get(f"/api/v1/repos/{repo_id}/issues.rss")
288 assert response.status_code == 200
289 assert "application/rss+xml" in response.headers["content-type"]
290
291
292 @pytest.mark.anyio
293 async def test_issues_rss_feed_includes_open_issues(
294 client: AsyncClient,
295 auth_headers: dict[str, str],
296 ) -> None:
297 """Issues RSS feed includes <item> elements for open issues."""
298 repo_id = await _create_public_repo(client, auth_headers, "rss-issues-items")
299 await _create_issue(client, auth_headers, repo_id, title="Bass muddy in chorus")
300 await _create_issue(client, auth_headers, repo_id, title="Drums too loud")
301
302 response = await client.get(f"/api/v1/repos/{repo_id}/issues.rss")
303 assert response.status_code == 200
304 body = response.text
305 assert "<item>" in body
306 assert "Bass muddy in chorus" in body
307 assert "Drums too loud" in body
308
309
310 @pytest.mark.anyio
311 async def test_issues_rss_feed_private_repo_returns_403(
312 client: AsyncClient,
313 auth_headers: dict[str, str],
314 ) -> None:
315 """Private repo issues RSS feed returns 403 Forbidden."""
316 repo_id = await _create_private_repo(client, auth_headers, "rss-issues-private")
317 response = await client.get(f"/api/v1/repos/{repo_id}/issues.rss")
318 assert response.status_code == 403
319
320
321 @pytest.mark.anyio
322 async def test_issues_rss_feed_nonexistent_repo_returns_404(client: AsyncClient) -> None:
323 """Issues RSS feed returns 404 for a non-existent repo."""
324 response = await client.get("/api/v1/repos/ghost-repo-id/issues.rss")
325 assert response.status_code == 404
326
327
328 @pytest.mark.anyio
329 async def test_issues_rss_feed_empty_repo(
330 client: AsyncClient,
331 auth_headers: dict[str, str],
332 ) -> None:
333 """Issues RSS feed is valid XML with no <item> tags when repo has no issues."""
334 repo_id = await _create_public_repo(client, auth_headers, "rss-issues-empty")
335 response = await client.get(f"/api/v1/repos/{repo_id}/issues.rss")
336 assert response.status_code == 200
337 assert "<item>" not in response.text
338
339
340 # ---------------------------------------------------------------------------
341 # GET /repos/{repo_id}/feed.atom — Atom 1.0 commit feed
342 # ---------------------------------------------------------------------------
343
344
345 @pytest.mark.anyio
346 async def test_commit_atom_feed_public_repo_200(
347 client: AsyncClient,
348 auth_headers: dict[str, str],
349 ) -> None:
350 """Public repo commit Atom feed returns 200 with application/atom+xml."""
351 repo_id = await _create_public_repo(client, auth_headers, "atom-commit-public-1")
352 response = await client.get(f"/api/v1/repos/{repo_id}/feed.atom")
353 assert response.status_code == 200
354 assert "application/atom+xml" in response.headers["content-type"]
355
356
357 @pytest.mark.anyio
358 async def test_commit_atom_feed_contains_atom_structure(
359 client: AsyncClient,
360 auth_headers: dict[str, str],
361 ) -> None:
362 """Atom feed body contains Atom 1.0 namespace and required <feed> tags."""
363 repo_id = await _create_public_repo(client, auth_headers, "atom-commit-structure")
364 response = await client.get(f"/api/v1/repos/{repo_id}/feed.atom")
365 assert response.status_code == 200
366 body = response.text
367 assert 'xmlns="http://www.w3.org/2005/Atom"' in body
368 assert "<feed" in body
369 assert "</feed>" in body
370 assert "<title>" in body
371 assert "<updated>" in body
372
373
374 @pytest.mark.anyio
375 async def test_commit_atom_feed_includes_entries(
376 client: AsyncClient,
377 auth_headers: dict[str, str],
378 db_session: AsyncSession,
379 ) -> None:
380 """Atom feed contains <entry> elements for each commit."""
381 repo_id = await _create_public_repo(client, auth_headers, "atom-commit-entries")
382 await _insert_commit(db_session, repo_id, "cmt003", "Introduce syncopated kick pattern")
383
384 response = await client.get(f"/api/v1/repos/{repo_id}/feed.atom")
385 assert response.status_code == 200
386 body = response.text
387 assert "<entry>" in body
388 assert "Introduce syncopated kick pattern" in body
389
390
391 @pytest.mark.anyio
392 async def test_commit_atom_feed_private_repo_returns_403(
393 client: AsyncClient,
394 auth_headers: dict[str, str],
395 ) -> None:
396 """Private repo Atom feed returns 403 Forbidden."""
397 repo_id = await _create_private_repo(client, auth_headers, "atom-commit-private")
398 response = await client.get(f"/api/v1/repos/{repo_id}/feed.atom")
399 assert response.status_code == 403
400
401
402 @pytest.mark.anyio
403 async def test_commit_atom_feed_nonexistent_repo_returns_404(client: AsyncClient) -> None:
404 """Atom commit feed returns 404 for a non-existent repo."""
405 response = await client.get("/api/v1/repos/ghost-repo-id/feed.atom")
406 assert response.status_code == 404
407
408
409 @pytest.mark.anyio
410 async def test_commit_atom_feed_empty_repo_valid_xml(
411 client: AsyncClient,
412 auth_headers: dict[str, str],
413 ) -> None:
414 """Atom feed is valid XML for a repo with only the auto-created initial commit.
415
416 Repo creation always inserts an initial commit, so the Atom feed always
417 contains at least one <entry>. This test verifies the feed envelope is
418 well-formed Atom 1.0.
419 """
420 repo_id = await _create_public_repo(client, auth_headers, "atom-commit-empty")
421 response = await client.get(f"/api/v1/repos/{repo_id}/feed.atom")
422 assert response.status_code == 200
423 body = response.text
424 assert 'xmlns="http://www.w3.org/2005/Atom"' in body
425 assert "<feed" in body
426 assert "</feed>" in body
427 assert "<updated>" in body
428
429
430 # ---------------------------------------------------------------------------
431 # XML builder unit tests (pure functions, no HTTP)
432 # ---------------------------------------------------------------------------
433
434
435 def _make_commit(commit_id: str = "abc123", message: str = "Add bass groove") -> CommitResponse:
436 """Build a minimal CommitResponse for unit tests."""
437 return CommitResponse(
438 commit_id=commit_id,
439 branch="main",
440 parent_ids=[],
441 message=message,
442 author="testuser",
443 timestamp=datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc),
444 )
445
446
447 def _make_release(
448 release_id: str = "rel-001",
449 tag: str = "v1.0",
450 title: str = "Initial Release",
451 ) -> ReleaseResponse:
452 """Build a minimal ReleaseResponse for unit tests."""
453 return ReleaseResponse(
454 release_id=release_id,
455 tag=tag,
456 title=title,
457 body="Release notes here.",
458 commit_id=None,
459 download_urls=build_empty_download_urls(),
460 author="testuser",
461 created_at=datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc),
462 )
463
464
465 def _make_issue(
466 issue_id: str = "iss-001",
467 number: int = 1,
468 title: str = "Verse feels unresolved",
469 ) -> IssueResponse:
470 """Build a minimal IssueResponse for unit tests."""
471 return IssueResponse(
472 issue_id=issue_id,
473 number=number,
474 title=title,
475 body="Needs more resolution.",
476 state="open",
477 labels=[],
478 author="testuser",
479 assignee=None,
480 milestone_id=None,
481 milestone_title=None,
482 updated_at=None,
483 comment_count=0,
484 created_at=datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc),
485 )
486
487
488 def test_rss_pub_date_utc_naive() -> None:
489 """_rss_pub_date treats naive datetimes as UTC."""
490 dt = datetime(2024, 6, 15, 12, 0, 0)
491 result = _rss_pub_date(dt)
492 assert "2024" in result
493 assert "+0000" in result
494
495
496 def test_rss_pub_date_with_tz() -> None:
497 """_rss_pub_date preserves timezone-aware datetimes."""
498 dt = datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
499 result = _rss_pub_date(dt)
500 assert "Sat, 15 Jun 2024 12:00:00 +0000" == result
501
502
503 def test_atom_date_format() -> None:
504 """_atom_date formats in RFC 3339 / ISO 8601."""
505 dt = datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
506 assert _atom_date(dt) == "2024-06-15T12:00:00Z"
507
508
509 def test_commit_rss_item_contains_required_fields() -> None:
510 """_commit_rss_item includes title, link, guid, and pubDate."""
511 commit = _make_commit(commit_id="abc123", message="Add bass groove")
512 xml = _commit_rss_item(commit, owner="miles", slug="kind-of-blue")
513 assert "<item>" in xml
514 assert "<title>Add bass groove</title>" in xml
515 assert "abc123" in xml
516 assert "/miles/kind-of-blue/commits/abc123" in xml
517 assert "<pubDate>" in xml
518 assert "<guid" in xml
519
520
521 def test_commit_rss_item_truncates_long_title() -> None:
522 """_commit_rss_item truncates commit message to 80 chars in <title>."""
523 long_msg = "A" * 120
524 commit = _make_commit(message=long_msg)
525 xml = _commit_rss_item(commit, owner="u", slug="r")
526 # The <title> element should be truncated to 80 chars
527 assert "<title>" + "A" * 80 + "</title>" in xml
528
529
530 def test_commit_rss_item_escapes_xml_chars() -> None:
531 """_commit_rss_item escapes < > & in commit messages."""
532 commit = _make_commit(message="Use <5 voices & keep it > quiet")
533 xml = _commit_rss_item(commit, owner="u", slug="r")
534 assert "&lt;" in xml
535 assert "&gt;" in xml
536 assert "&amp;" in xml
537
538
539 def test_release_rss_item_title_format() -> None:
540 """_release_rss_item formats title as 'Release {tag}: {name}'."""
541 release = _make_release(tag="v2.0", title="Big Refactor")
542 xml = _release_rss_item(release, owner="miles", slug="kind-of-blue")
543 assert "Release v2.0: Big Refactor" in xml
544
545
546 def test_release_rss_item_link_includes_tag() -> None:
547 """_release_rss_item link path includes the release tag."""
548 release = _make_release(tag="v1.0")
549 xml = _release_rss_item(release, owner="u", slug="r")
550 assert "/u/r/releases/v1.0" in xml
551
552
553 def test_release_rss_item_no_enclosure_when_no_mp3() -> None:
554 """_release_rss_item omits <enclosure> when no mp3 download URL is set."""
555 release = _make_release()
556 xml = _release_rss_item(release, owner="u", slug="r")
557 assert "<enclosure" not in xml
558
559
560 def test_issue_rss_item_contains_required_fields() -> None:
561 """_issue_rss_item includes title, link, guid, and pubDate."""
562 issue = _make_issue(number=7, title="Chord clash on beat 3")
563 xml = _issue_rss_item(issue, owner="miles", slug="kind-of-blue")
564 assert "<item>" in xml
565 assert "Chord clash on beat 3" in xml
566 assert "/miles/kind-of-blue/issues/7" in xml
567 assert "<guid" in xml
568
569
570 def test_commit_atom_entry_contains_required_fields() -> None:
571 """_commit_atom_entry includes title, link href, id, updated, and summary."""
572 commit = _make_commit(commit_id="def456", message="Reharmonise verse")
573 xml = _commit_atom_entry(commit, owner="miles", slug="kind-of-blue")
574 assert "<entry>" in xml
575 assert "Reharmonise verse" in xml
576 assert 'href="/miles/kind-of-blue/commits/def456"' in xml
577 assert "<updated>" in xml
578 assert "<id>" in xml
579
580
581 def test_build_rss_envelope_structure() -> None:
582 """_build_rss_envelope produces a well-formed RSS 2.0 document."""
583 xml = _build_rss_envelope(
584 title="Test Feed",
585 link="https://example.com",
586 description="A test feed",
587 items=["<item><title>Item 1</title></item>"],
588 )
589 assert '<?xml version="1.0" encoding="UTF-8"?>' in xml
590 assert '<rss version="2.0">' in xml
591 assert "<title>Test Feed</title>" in xml
592 assert "Item 1" in xml
593
594
595 def test_build_atom_envelope_structure() -> None:
596 """_build_atom_envelope produces a well-formed Atom 1.0 document."""
597 xml = _build_atom_envelope(
598 title="Atom Feed",
599 feed_id="tag:example:feed",
600 updated="2024-06-15T12:00:00Z",
601 entries=["<entry><title>E1</title></entry>"],
602 )
603 assert '<?xml version="1.0" encoding="UTF-8"?>' in xml
604 assert 'xmlns="http://www.w3.org/2005/Atom"' in xml
605 assert "<title>Atom Feed</title>" in xml
606 assert "E1" in xml