gabriel / musehub public
test_musehub_ui_jsonld.py python
267 lines 9.8 KB
e6fad116 Remove all Stori, Maestro, and AgentCeption references; rebrand to Muse VCS Gabriel Cardona <gabriel@tellurstori.com> 6d ago
1 """Unit tests for the MuseHub JSON-LD structured data helpers.
2
3 Covers — jsonld_repo and jsonld_release produce valid
4 schema.org/MusicComposition and schema.org/MusicRecording dicts.
5 render_jsonld_script produces a safe, well-formed <script> tag.
6
7 Tests:
8 - test_jsonld_repo_returns_music_composition_type
9 - test_jsonld_repo_includes_required_fields
10 - test_jsonld_repo_includes_genre_when_tags_present
11 - test_jsonld_repo_omits_genre_when_no_tags
12 - test_jsonld_repo_includes_key_signature_when_present
13 - test_jsonld_repo_includes_tempo_when_present
14 - test_jsonld_repo_omits_key_and_tempo_when_absent
15 - test_jsonld_release_returns_music_recording_type
16 - test_jsonld_release_includes_required_fields
17 - test_jsonld_release_includes_genre_from_repo_tags
18 - test_jsonld_release_falls_back_to_repo_owner_when_no_author
19 - test_jsonld_release_uses_tag_when_title_empty
20 - test_render_jsonld_script_wraps_in_script_tag
21 - test_render_jsonld_script_escapes_closing_tag_xss
22 - test_render_jsonld_script_preserves_unicode
23 """
24 from __future__ import annotations
25
26 import json
27 from datetime import datetime, timezone
28 from unittest.mock import MagicMock
29
30 import pytest
31
32 from musehub.api.routes.musehub.ui_jsonld import (
33 jsonld_release,
34 jsonld_repo,
35 render_jsonld_script,
36 )
37 from musehub.models.musehub import ReleaseDownloadUrls, ReleaseResponse, RepoResponse
38
39
40 # ---------------------------------------------------------------------------
41 # Fixtures — minimal valid model instances
42 # ---------------------------------------------------------------------------
43
44
45 def _make_repo(
46 *,
47 name: str = "Kind of Blue",
48 owner: str = "miles_davis",
49 description: str = "Landmark modal jazz album",
50 tags: list[str] | None = None,
51 key_signature: str | None = None,
52 tempo_bpm: int | None = None,
53 ) -> RepoResponse:
54 """Build a minimal RepoResponse for testing."""
55 return RepoResponse(
56 repo_id="aabbccdd",
57 name=name,
58 owner=owner,
59 slug="kind-of-blue",
60 visibility="public",
61 owner_user_id="user-uuid-123",
62 clone_url="https://musehub.app/api/v1/musehub/repos/aabbccdd",
63 description=description,
64 tags=tags or [],
65 key_signature=key_signature,
66 tempo_bpm=tempo_bpm,
67 created_at=datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc),
68 )
69
70
71 def _make_release(
72 *,
73 tag: str = "v1.0",
74 title: str = "First Release",
75 body: str = "Initial recording session.",
76 author: str = "miles_davis",
77 ) -> ReleaseResponse:
78 """Build a minimal ReleaseResponse for testing."""
79 return ReleaseResponse(
80 release_id="release-uuid-456",
81 tag=tag,
82 title=title,
83 body=body,
84 commit_id=None,
85 download_urls=ReleaseDownloadUrls(),
86 author=author,
87 created_at=datetime(2024, 3, 1, 9, 0, 0, tzinfo=timezone.utc),
88 )
89
90
91 # ---------------------------------------------------------------------------
92 # jsonld_repo — MusicComposition
93 # ---------------------------------------------------------------------------
94
95
96 def test_jsonld_repo_returns_music_composition_type() -> None:
97 """JSON-LD for a repo always declares @type MusicComposition."""
98 repo = _make_repo()
99 data = jsonld_repo(repo, "https://example.com/musehub/ui/miles/kind-of-blue")
100 assert data["@type"] == "MusicComposition"
101 assert data["@context"] == "https://schema.org"
102
103
104 def test_jsonld_repo_includes_required_fields() -> None:
105 """Repo JSON-LD includes name, description, url, dateCreated, creator."""
106 repo = _make_repo()
107 url = "https://example.com/musehub/ui/miles/kind-of-blue"
108 data = jsonld_repo(repo, url)
109
110 assert data["name"] == "Kind of Blue"
111 assert data["description"] == "Landmark modal jazz album"
112 assert data["url"] == url
113 assert "2024-01-15" in str(data["dateCreated"])
114 creator = data["creator"]
115 assert isinstance(creator, dict)
116 assert creator["@type"] == "Person"
117 assert creator["name"] == "miles_davis"
118
119
120 def test_jsonld_repo_includes_genre_when_tags_present() -> None:
121 """Tags are mapped to the genre field when the repo has tags."""
122 repo = _make_repo(tags=["jazz", "modal", "F minor"])
123 data = jsonld_repo(repo, "https://example.com/repo")
124 assert data["genre"] == ["jazz", "modal", "F minor"]
125
126
127 def test_jsonld_repo_omits_genre_when_no_tags() -> None:
128 """The genre field is absent when the repo has no tags."""
129 repo = _make_repo(tags=[])
130 data = jsonld_repo(repo, "https://example.com/repo")
131 assert "genre" not in data
132
133
134 def test_jsonld_repo_includes_key_signature_when_present() -> None:
135 """musicalKey is populated when repo has a key_signature."""
136 repo = _make_repo(key_signature="Bb major")
137 data = jsonld_repo(repo, "https://example.com/repo")
138 assert data["musicalKey"] == "Bb major"
139
140
141 def test_jsonld_repo_includes_tempo_when_present() -> None:
142 """tempo is populated (as a string) when repo has tempo_bpm."""
143 repo = _make_repo(tempo_bpm=120)
144 data = jsonld_repo(repo, "https://example.com/repo")
145 assert data["tempo"] == "120"
146
147
148 def test_jsonld_repo_omits_key_and_tempo_when_absent() -> None:
149 """musicalKey and tempo are absent when the model fields are None."""
150 repo = _make_repo(key_signature=None, tempo_bpm=None)
151 data = jsonld_repo(repo, "https://example.com/repo")
152 assert "musicalKey" not in data
153 assert "tempo" not in data
154
155
156 # ---------------------------------------------------------------------------
157 # jsonld_release — MusicRecording
158 # ---------------------------------------------------------------------------
159
160
161 def test_jsonld_release_returns_music_recording_type() -> None:
162 """JSON-LD for a release always declares @type MusicRecording."""
163 release = _make_release()
164 repo = _make_repo()
165 data = jsonld_release(release, repo, "https://example.com/release/v1.0")
166 assert data["@type"] == "MusicRecording"
167 assert data["@context"] == "https://schema.org"
168
169
170 def test_jsonld_release_includes_required_fields() -> None:
171 """Release JSON-LD includes name, description, url, datePublished, byArtist, inAlbum."""
172 release = _make_release()
173 repo = _make_repo()
174 url = "https://example.com/musehub/ui/miles/kind-of-blue/releases/v1.0"
175 data = jsonld_release(release, repo, url)
176
177 assert data["name"] == "First Release"
178 assert data["description"] == "Initial recording session."
179 assert data["url"] == url
180 assert "2024-03-01" in str(data["datePublished"])
181
182 artist = data["byArtist"]
183 assert isinstance(artist, dict)
184 assert artist["@type"] == "Person"
185 assert artist["name"] == "miles_davis"
186
187 album = data["inAlbum"]
188 assert isinstance(album, dict)
189 assert album["@type"] == "MusicAlbum"
190 assert album["name"] == "Kind of Blue"
191
192
193 def test_jsonld_release_includes_genre_from_repo_tags() -> None:
194 """Release JSON-LD inherits genre from the parent repo's tags."""
195 release = _make_release()
196 repo = _make_repo(tags=["bebop", "quintet"])
197 data = jsonld_release(release, repo, "https://example.com/release")
198 assert data["genre"] == ["bebop", "quintet"]
199
200
201 def test_jsonld_release_falls_back_to_repo_owner_when_no_author() -> None:
202 """byArtist falls back to repo.owner when release.author is empty."""
203 release = _make_release(author="")
204 repo = _make_repo(owner="coltrane")
205 data = jsonld_release(release, repo, "https://example.com/release")
206 artist = data["byArtist"]
207 assert isinstance(artist, dict)
208 assert artist["name"] == "coltrane"
209
210
211 def test_jsonld_release_uses_tag_when_title_empty() -> None:
212 """name falls back to the release tag when title is an empty string."""
213 release = _make_release(title="", tag="v2.0-beta")
214 repo = _make_repo()
215 data = jsonld_release(release, repo, "https://example.com/release")
216 assert data["name"] == "v2.0-beta"
217
218
219 # ---------------------------------------------------------------------------
220 # render_jsonld_script
221 # ---------------------------------------------------------------------------
222
223
224 def test_render_jsonld_script_wraps_in_script_tag() -> None:
225 """Rendered output is a <script type="application/ld+json"> tag."""
226 data: dict[str, object] = {"@context": "https://schema.org", "@type": "MusicComposition", "name": "Test"}
227 script = render_jsonld_script(data)
228 assert script.startswith('<script type="application/ld+json">')
229 assert script.endswith("</script>")
230
231
232 def test_render_jsonld_script_contains_valid_json() -> None:
233 """The content inside the script tag is valid JSON matching the input dict."""
234 data: dict[str, object] = {
235 "@context": "https://schema.org",
236 "@type": "MusicComposition",
237 "name": "Blue in Green",
238 }
239 script = render_jsonld_script(data)
240 inner = script.removeprefix('<script type="application/ld+json">').removesuffix("</script>")
241 parsed = json.loads(inner)
242 assert parsed["name"] == "Blue in Green"
243 assert parsed["@type"] == "MusicComposition"
244
245
246 def test_render_jsonld_script_escapes_closing_tag_xss() -> None:
247 """</script> sequences inside JSON values are escaped to prevent XSS."""
248 data: dict[str, object] = {
249 "@context": "https://schema.org",
250 "name": "Attack</script><script>alert(1)//",
251 }
252 script = render_jsonld_script(data)
253 # Extract only the JSON content between the opening and closing script tags.
254 inner = script.removeprefix('<script type="application/ld+json">').removesuffix("</script>")
255 # The closing tag sequence must not appear verbatim inside the JSON payload.
256 assert "</script>" not in inner
257
258
259 def test_render_jsonld_script_preserves_unicode() -> None:
260 """Non-ASCII characters (e.g. accented, CJK) are preserved without escaping."""
261 data: dict[str, object] = {
262 "@context": "https://schema.org",
263 "name": "Caf\u00e9 M\u00fasica \u3084\u307e\u3068",
264 }
265 script = render_jsonld_script(data)
266 assert "Caf\u00e9" in script
267 assert "\u3084\u307e\u3068" in script