music_test.mx raw
1 package protocol
2
3 import "testing"
4
5 // The track example from FEATURES_MUSIC.md, tag for tag. A parse followed by a
6 // render must reproduce this list exactly: the struct order IS the example
7 // order, so a field added in the wrong place shows up here.
8 func docTrackTags() (tags [][]string) {
9 tags = appendKV(tags, TagD, "PT-ABC-26-00001")
10 tags = appendKV(tags, TagTitle, "Song Title")
11 tags = appendKV(tags, TagArtist, "Band Name")
12 tags = appendTag(tags, (&Participant{Pubkey: artistPub, Relay: "wss://relay.musiquay.com", Role: "performer"}).Tag())
13 tags = appendTag(tags, (&Address{Kind: KindAlbum, Pubkey: ownerPub, D: "album-d", Relay: "wss://relay.musiquay.com"}).Tag())
14 tags = appendKV(tags, TagAlbum, "Album Name")
15 tags = appendKV(tags, TagTrack, "5/12")
16 tags = appendKV(tags, TagDisc, "1/2")
17 tags = appendKV(tags, TagDuration, "247.32")
18 tags = appendKV(tags, TagGenre, "post-punk")
19 tags = appendKV(tags, TagT, "postpunk")
20 tags = appendKV(tags, TagReleaseDate, "2026-06-15")
21 tags = appendKV(tags, TagISRC, "PT-ABC-26-00001")
22 tags = appendKV(tags, TagLanguage, "en")
23 tags = appendKV(tags, TagBPM, "138")
24 tags = appendKV(tags, TagKey, "Am")
25 tags = appendKV(tags, TagCopyright, "CC BY-NC-SA 4.0")
26 tags = appendTag(tags, (&Price{Amount: "500", Unit: "sat"}).Tag())
27 tags = appendTag(tags, (&BlobRef{Sha256: coverHash, Server: "https://blossom.musiquay.com"}).Tag())
28 tags = appendKV(tags, TagI, "musicbrainz:recording:8be5c8c0-7b52-4a2d-9d4e-1c2f3a4b5c6d")
29 tags = appendKV(tags, TagK, "musicbrainz:recording")
30 tags = appendTag(tags, (&Encoding{Sha256: flacHash, Spec: "flac-24bit-96kHz", Server: "https://blossom.musiquay.com", MIME: "audio/flac", Size: 48216387, HasSize: true}).Tag())
31 tags = appendTag(tags, (&Encoding{Sha256: mp3Hash, Spec: "mp3-128kbps-VBR", Server: "https://blossom.musiquay.com", MIME: "audio/mpeg", Size: 9110334, HasSize: true}).Tag())
32 tags = appendTag(tags, (&Encoding{Sha256: opusHash, Spec: "opus-96kbps", Server: "https://blossom.musiquay.com", MIME: "audio/ogg", Size: 3942210, HasSize: true}).Tag())
33 tags = appendTag(tags, (&Encoding{Sha256: flacHash}).XTag())
34 tags = appendTag(tags, (&Encoding{Sha256: mp3Hash}).XTag())
35 tags = appendTag(tags, (&Encoding{Sha256: opusHash}).XTag())
36 tags = appendKV(tags, TagMaster, flacHash)
37 return
38 }
39
40 const (
41 artistPub = "3333333333333333333333333333333333333333333333333333333333333333"
42 coverHash = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
43 flacHash = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
44 mp3Hash = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
45 opusHash = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
46 )
47
48 func TestTrackRoundTrip(t *testing.T) {
49 want := docTrackTags()
50 tr, err := ParseTrack(want, "Liner notes.")
51 if err != nil {
52 t.Fatalf("parse: %s", err.Error())
53 }
54 if verr := tr.Validate(); verr != nil {
55 t.Fatalf("validate: %s", verr.Error())
56 }
57 if w := tr.Warnings(); len(w) != 0 {
58 t.Fatalf("warnings on a complete track: %s", joinWarnings(w))
59 }
60 got := tr.Tags()
61 if !sameTags(got, want) {
62 t.Fatalf("round trip differs:\n got %s\nwant %s", joinTags(got), joinTags(want))
63 }
64 if tr.Content != "Liner notes." {
65 t.Fatalf("content = %q", tr.Content)
66 }
67 if tr.Album == nil || tr.Album.Kind != KindAlbum || tr.Album.Relay != "wss://relay.musiquay.com" {
68 t.Fatalf("album reference = %v", tr.Album)
69 }
70 if len(tr.Encodings) != 3 {
71 t.Fatalf("encodings = %d, want 3", len(tr.Encodings))
72 }
73 if tr.Encodings[0].Size != 48216387 || !tr.Encodings[0].HasSize {
74 t.Fatalf("first encoding = %+v", tr.Encodings[0])
75 }
76 if tr.Master != flacHash {
77 t.Fatalf("master = %q", tr.Master)
78 }
79 if len(tr.External) != 1 || tr.External[0].I != "musicbrainz:recording:8be5c8c0-7b52-4a2d-9d4e-1c2f3a4b5c6d" || tr.External[0].K != "musicbrainz:recording" {
80 t.Fatalf("external ids = %v", tr.External)
81 }
82 if tr.Duration != "247.32" || tr.BPM != "138" || tr.ISRC != "PT-ABC-26-00001" {
83 t.Fatalf("numeric-ish fields: %+v", tr)
84 }
85 }
86
87 // The d tag SHOULD be the ISRC, and the address is what everything references.
88 func TestTrackAddress(t *testing.T) {
89 tr, err := ParseTrack(docTrackTags(), "")
90 if err != nil {
91 t.Fatal(err.Error())
92 }
93 a := &Address{Kind: KindTrack, Pubkey: ownerPub, D: tr.D}
94 if a.String() != "32210:"|ownerPub|":PT-ABC-26-00001" {
95 t.Fatalf("address = %q", a.String())
96 }
97 }
98
99 func TestTrackValidateRejects(t *testing.T) {
100 // A master that is not one of the encodings.
101 tr, err := ParseTrack(docTrackTags(), "")
102 if err != nil {
103 t.Fatal(err.Error())
104 }
105 tr.Master = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
106 if verr := tr.Validate(); verr == nil {
107 t.Fatal("a master outside the encodings was accepted")
108 }
109 // A bad codec spec on an encoding.
110 tr2, _ := ParseTrack(docTrackTags(), "")
111 tr2.Encodings[0].Spec = "flac-"
112 if verr := tr2.Validate(); verr == nil {
113 t.Fatal("a malformed codec spec was accepted")
114 }
115 // A short blob hash.
116 tr3, _ := ParseTrack(docTrackTags(), "")
117 tr3.Encodings[0].Sha256 = "abcd"
118 if verr := tr3.Validate(); verr == nil {
119 t.Fatal("a short blob hash was accepted")
120 }
121 // An album reference of the wrong kind.
122 tr4, _ := ParseTrack(docTrackTags(), "")
123 tr4.Album.Kind = KindPlaylist
124 if verr := tr4.Validate(); verr == nil {
125 t.Fatal("an album reference of the wrong kind was accepted")
126 }
127 // No d tag at all: the address is the identity.
128 tr5 := &Track{Title: "x"}
129 if verr := tr5.Validate(); verr == nil {
130 t.Fatal("a track with no d tag was accepted")
131 }
132 }
133
134 func TestTrackWarnings(t *testing.T) {
135 tr := &Track{D: "x", Title: "T"}
136 tr.Encodings = push(tr.Encodings, Encoding{Sha256: flacHash, Spec: "flac-16bit-44.1kHz"})
137 w := tr.Warnings()
138 if len(w) < 2 {
139 t.Fatalf("warnings = %s", joinWarnings(w))
140 }
141 tr.Participants = push(tr.Participants, Participant{Pubkey: ownerPub, Role: "wizard"})
142 found := false
143 for _, s := range tr.Warnings() {
144 if s == "unknown role: wizard" {
145 found = true
146 }
147 }
148 if !found {
149 t.Fatal("an unknown role did not warn")
150 }
151 }
152
153 // Unknown tags survive a parse and are re-emitted: an older build must not
154 // strip a newer field off an event it stores or forwards.
155 func TestTrackKeepsUnknownTags(t *testing.T) {
156 tags := appendTags(docTrackTags(), [][]string{
157 {"vibe", "shoegaze"},
158 {"z", "future"},
159 })
160 tr, err := ParseTrack(tags, "")
161 if err != nil {
162 t.Fatal(err.Error())
163 }
164 if len(tr.Extra) != 2 {
165 t.Fatalf("extra = %s", joinTags(tr.Extra))
166 }
167 got := tr.Tags()
168 if len(got) != len(tags) {
169 t.Fatalf("round trip lost tags: %d vs %d", len(got), len(tags))
170 }
171 if got[len(got)-2][0] != "vibe" || got[len(got)-1][0] != "z" {
172 t.Fatalf("extra tags not re-emitted: %s", joinTags(got))
173 }
174 }
175
176 // The album example from FEATURES_MUSIC.md: the ordered a tags are the
177 // tracklist, and the order must survive.
178 func TestAlbumRoundTrip(t *testing.T) {
179 var want [][]string
180 want = appendKV(want, TagD, "album-d")
181 want = appendKV(want, TagTitle, "Album Name")
182 want = appendKV(want, TagArtist, "Band Name")
183 want = appendTag(want, (&Participant{Pubkey: artistPub, Relay: "wss://relay.musiquay.com", Role: "performer"}).Tag())
184 want = appendTag(want, (&Participant{Pubkey: ownerPub, Relay: "wss://relay.musiquay.com", Role: "label"}).Tag())
185 want = appendKV(want, TagReleaseDate, "2026-06-15")
186 want = appendKV(want, TagGenre, "post-punk")
187 want = appendKV(want, TagT, "postpunk")
188 want = appendKV(want, TagLanguage, "en")
189 want = appendKV(want, TagCopyright, "CC BY-NC-SA 4.0")
190 want = appendTag(want, (&Price{Amount: "5000", Unit: "sat"}).Tag())
191 want = appendTag(want, (&BlobRef{Sha256: coverHash, Server: "https://blossom.musiquay.com"}).Tag())
192 want = appendKV(want, TagI, "musicbrainz:release-group:5c9e6d6c-2c2e-4a7b-9c3f-3a4b5c6d7e8f")
193 want = appendKV(want, TagK, "musicbrainz:release-group")
194 for i := 1; i <= 3; i++ {
195 want = appendTag(want, (&Address{Kind: KindTrack, Pubkey: ownerPub, D: "track-d-" | intStr(int64(i)), Relay: "wss://relay.musiquay.com"}).Tag())
196 }
197 al, err := ParseAlbum(want, "Release notes.")
198 if err != nil {
199 t.Fatalf("parse: %s", err.Error())
200 }
201 if verr := al.Validate(); verr != nil {
202 t.Fatalf("validate: %s", verr.Error())
203 }
204 if len(al.Tracks) != 3 || al.Tracks[2].D != "track-d-3" {
205 t.Fatalf("tracklist = %s", joinTags(al.Tags()))
206 }
207 if got := al.Tags(); !sameTags(got, want) {
208 t.Fatalf("round trip differs:\n got %s\nwant %s", joinTags(got), joinTags(want))
209 }
210 // A tracklist entry of the wrong kind is rejected.
211 al.Tracks[1].Kind = KindAlbum
212 if verr := al.Validate(); verr == nil {
213 t.Fatal("a non-track address in a tracklist was accepted")
214 }
215 }
216
217 // The playlist example: NIP-51 set conventions, members as ordered a tags.
218 func TestPlaylistRoundTrip(t *testing.T) {
219 var want [][]string
220 want = appendKV(want, TagD, "my-favourite-deep-cuts")
221 want = appendKV(want, TagTitle, "My Favourite Deep Cuts")
222 want = appendKV(want, TagDescription, "Tracks that never got the attention they deserved.")
223 want = appendTag(want, (&BlobRef{Sha256: coverHash, Server: "https://blossom.musiquay.com"}).Tag())
224 want = appendTag(want, (&Address{Kind: KindTrack, Pubkey: ownerPub, D: "track-d-1", Relay: "wss://relay.musiquay.com"}).Tag())
225 want = appendTag(want, (&Address{Kind: KindTrack, Pubkey: artistPub, D: "track-d-2", Relay: "wss://relay.musiquay.com"}).Tag())
226 pl, err := ParsePlaylist(want, "")
227 if err != nil {
228 t.Fatalf("parse: %s", err.Error())
229 }
230 if verr := pl.Validate(); verr != nil {
231 t.Fatalf("validate: %s", verr.Error())
232 }
233 if pl.IsPrivate() {
234 t.Fatal("a public playlist reported as private")
235 }
236 if got := pl.Tags(); !sameTags(got, want) {
237 t.Fatalf("round trip differs:\n got %s\nwant %s", joinTags(got), joinTags(want))
238 }
239 // A private playlist has no member tags: the members are in content.
240 priv := &Playlist{D: "secret", Title: "Secret", Content: "nip44-ciphertext"}
241 if !priv.IsPrivate() {
242 t.Fatal("a playlist with members in content is private")
243 }
244 if verr := priv.Validate(); verr != nil {
245 t.Fatalf("validate private: %s", verr.Error())
246 }
247 }
248
249 // The paid play report from FEATURES_MUSIC.md, and the statement its blind
250 // signature covers.
251 func TestPlayReportPaidRoundTrip(t *testing.T) {
252 var want [][]string
253 want = appendTag(want, (&Address{Kind: KindTrack, Pubkey: ownerPub, D: "track-d", Relay: "wss://relay.musiquay.com"}).Tag())
254 want = appendKV(want, TagP, artistPub)
255 want = appendKV(want, TagX, mp3Hash)
256 want = appendKV(want, TagServer, "https://blossom.musiquay.com")
257 want = appendKV(want, TagCodec, "mp3-128kbps-VBR")
258 want = appendKV(want, TagT0, "1758130000")
259 want = appendKV(want, TagT1, "1758130247")
260 want = appendKV(want, TagBlind, "02blind")
261 want = appendKV(want, TagHost, ownerPub)
262 want = appendKV(want, TagStatement, "73746174656d656e74")
263 d := []string{:3}
264 d[0] = TagDleq
265 d[1] = "02e"
266 d[2] = "02s"
267 want = appendTag(want, d)
268
269 r, err := ParsePlayReport(want, "")
270 if err != nil {
271 t.Fatalf("parse: %s", err.Error())
272 }
273 if verr := r.Validate(); verr != nil {
274 t.Fatalf("validate: %s", verr.Error())
275 }
276 if !r.IsPaid() {
277 t.Fatal("a report with host proof is not marked paid")
278 }
279 if got := r.Tags(); !sameTags(got, want) {
280 t.Fatalf("round trip differs:\n got %s\nwant %s", joinTags(got), joinTags(want))
281 }
282 if r.Dleq == nil || r.Dleq.E != "02e" || r.Dleq.S != "02s" {
283 t.Fatalf("dleq = %v", r.Dleq)
284 }
285 stmt := PlayStatement(r.Asset.String(), r.Blob, r.T0, r.T1)
286 if stmt != "musiquay.play:v1|32210:"|ownerPub|":track-d|"|mp3Hash|"|1758130000|1758130247" {
287 t.Fatalf("statement = %q", stmt)
288 }
289 }
290
291 func TestPlayReportFreeIsUnproven(t *testing.T) {
292 var tags [][]string
293 tags = appendTag(tags, (&Address{Kind: KindTrack, Pubkey: ownerPub, D: "track-d"}).Tag())
294 tags = appendKV(tags, TagP, artistPub)
295 tags = appendKV(tags, TagX, mp3Hash)
296 tags = appendKV(tags, TagServer, "https://blossom.musiquay.com")
297 tags = appendKV(tags, TagCodec, "opus-96kbps")
298 tags = appendKV(tags, TagT0, "1758130000")
299 tags = appendKV(tags, TagT1, "1758130247")
300 r, err := ParsePlayReport(tags, "")
301 if err != nil {
302 t.Fatal(err.Error())
303 }
304 if r.IsPaid() {
305 t.Fatal("a free play reported as paid")
306 }
307 if verr := r.Validate(); verr != nil {
308 t.Fatalf("validate: %s", verr.Error())
309 }
310 if got := r.Tags(); !sameTags(got, tags) {
311 t.Fatalf("round trip differs:\n got %v\nwant %v", got, tags)
312 }
313 // A paid report missing its DLEQ proof is unverifiable by anyone but the
314 // host, so it is rejected rather than stored as if certified.
315 r.Blind = "02blind"
316 if verr := r.Validate(); verr == nil {
317 t.Fatal("a paid report without a dleq proof was accepted")
318 }
319 // The asset of a play report is a track; an album address is not an asset.
320 bad, _ := ParsePlayReport(tags, "")
321 bad.Asset.Kind = KindAlbum
322 if verr := bad.Validate(); verr == nil {
323 t.Fatal("a play report on an album address was accepted")
324 }
325 }
326
327 // sameTags compares tag lists element by element.
328 func sameTags(a, b [][]string) (ok bool) {
329 if len(a) != len(b) {
330 return false
331 }
332 for i := range a {
333 if len(a[i]) != len(b[i]) {
334 return false
335 }
336 for j := range a[i] {
337 if a[i][j] != b[i][j] {
338 return false
339 }
340 }
341 }
342 return true
343 }
344
345 // joinWarnings renders a warning list for a failure message. Moxie's fmt has
346 // no reflective slice formatting, so a list is joined explicitly.
347 func joinWarnings(w []string) (s string) {
348 for i := range w {
349 if i > 0 {
350 s = s | "; "
351 }
352 s = s | w[i]
353 }
354 return s
355 }
356
357 // joinTags renders a tag list for a failure message: Moxie's fmt has no
358 // reflective slice formatting, and "?" tells the reader nothing.
359 func joinTags(tags [][]string) (s string) {
360 for i := range tags {
361 if i > 0 {
362 s = s | "\n"
363 }
364 s = s | "["
365 for j := range tags[i] {
366 if j > 0 {
367 s = s | " "
368 }
369 s = s | tags[i][j]
370 }
371 s = s | "]"
372 }
373 return s
374 }
375