protocolwire_test.mx raw
1 package protocolwire
2
3 import (
4 "testing"
5
6 "git.smesh.lol/musiquay/pkg/protocol"
7 "git.smesh.lol/nostr/pkg/core"
8 )
9
10 // The tag list below is the wire form of the music spec's complete track
11 // example, written out row for row and independently of protocol.Track.Tags(),
12 // so that TagsJSON is measured against the spec rather than against the
13 // encoder under the same package. The Tags() path is covered separately, by
14 // TestTrackTagsReadsItsOwnRows.
15 const (
16 fxArtist = "3333333333333333333333333333333333333333333333333333333333333333"
17 fxOwner = "4444444444444444444444444444444444444444444444444444444444444444"
18 fxCover = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
19 fxFlac = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
20 fxRelay = "wss://relay.musiquay.com"
21 fxServer = "https://blossom.musiquay.com"
22 )
23
24 func fixtureTags() (tags [][]string) {
25 tags = [][]string{
26 []string{protocol.TagD, "PT-ABC-26-00001"},
27 []string{protocol.TagTitle, "Song Title"},
28 []string{protocol.TagArtist, "Band Name"},
29 []string{protocol.TagP, fxArtist, fxRelay, "performer"},
30 []string{protocol.TagA, "32211:" | fxOwner | ":album-d", fxRelay},
31 []string{protocol.TagAlbum, "Album Name"},
32 []string{protocol.TagTrack, "5/12"},
33 []string{protocol.TagDisc, "1/2"},
34 []string{protocol.TagDuration, "247.32"},
35 []string{protocol.TagGenre, "post-punk"},
36 []string{protocol.TagT, "postpunk"},
37 []string{protocol.TagReleaseDate, "2026-06-15"},
38 []string{protocol.TagISRC, "PT-ABC-26-00001"},
39 []string{protocol.TagLanguage, "en"},
40 []string{protocol.TagBPM, "138"},
41 []string{protocol.TagKey, "Am"},
42 []string{protocol.TagCopyright, "CC BY-NC-SA 4.0"},
43 []string{protocol.TagPrice, "500", "sat"},
44 []string{protocol.TagImage, fxCover, fxServer},
45 []string{protocol.TagI, "musicbrainz:recording:8be5c8c0"},
46 []string{protocol.TagK, "musicbrainz:recording"},
47 []string{protocol.TagAudio, fxFlac, "flac-24bit-96kHz", fxServer, "audio/flac", "48216387"},
48 []string{protocol.TagX, fxFlac},
49 []string{protocol.TagMaster, fxFlac},
50 }
51 return
52 }
53
54 // toCoreTags builds the nostr tag list a reader receives. The core tag list is
55 // [][]string, so each row is copied across explicitly.
56 func toCoreTags(rows [][]string) (out [][]string) {
57 out = [][]string{:len(rows)}
58 for i := range rows {
59 row := []string{:len(rows[i])}
60 for j := range rows[i] {
61 row[j] = rows[i][j]
62 }
63 out[i] = row
64 }
65 return
66 }
67
68 func contains(hay, needle string) (ok bool) {
69 n := int32(len(needle))
70 if n == 0 {
71 return true
72 }
73 for i := int32(0); i+n <= int32(len(hay)); i++ {
74 if hay[i:i+n] == needle {
75 return true
76 }
77 }
78 return false
79 }
80
81 // Render a payload's tags and assert the JSON is an array of arrays holding
82 // the rows the vocabulary defines. The d/title/audio rows are checked by
83 // value, the frame by shape: a field emitted in the wrong place is a wire bug.
84 func TestTagsJSONShape(t *testing.T) {
85 tags := fixtureTags()
86 j := TagsJSON(tags)
87 if len(j) < 4 || j[0:2] != "[[" || j[len(j)-2:] != "]]" {
88 t.Fatalf("tags JSON is not an array of arrays: %s", j)
89 }
90 wantRows := []string{
91 "[\"d\",\"PT-ABC-26-00001\"]",
92 "[\"title\",\"Song Title\"]",
93 "[\"artist\",\"Band Name\"]",
94 "[\"audio\",\"" | fxFlac | "\",\"flac-24bit-96kHz\",\"" | fxServer | "\",\"audio/flac\",\"48216387\"]",
95 "[\"x\",\"" | fxFlac | "\"]",
96 "[\"master\",\"" | fxFlac | "\"]",
97 }
98 for i := range wantRows {
99 if !contains(j, wantRows[i]) {
100 t.Fatalf("tags JSON is missing %s: %s", wantRows[i], j)
101 }
102 }
103 if !contains(j, "[\"price\",\"500\",\"sat\"]") {
104 t.Fatalf("tags JSON lost the price row: %s", j)
105 }
106 }
107
108 // The unsigned object must carry the exact key order every existing publisher
109 // uses: kind, content, tags, created_at, pubkey.
110 func TestUnsignedJSON(t *testing.T) {
111 got := UnsignedJSON(protocol.KindTrack, "[[\"d\",\"x\"]]", "liner \"notes\"", "abc", 123)
112 want := "{\"kind\":32210,\"content\":\"liner \\\"notes\\\"\",\"tags\":[[\"d\",\"x\"]],\"created_at\":123,\"pubkey\":\"abc\"}"
113 if got != want {
114 t.Fatalf("UnsignedJSON = %s, want %s", got, want)
115 }
116 }
117
118 // An absent tag list is an empty JSON array, never an empty string: the value
119 // sits beside the mandatory "tags" key of the unsigned event object.
120 func TestTagsJSONEmpty(t *testing.T) {
121 if got := TagsJSON(nil); got != "[]" {
122 t.Fatalf("TagsJSON(nil) = %s", got)
123 }
124 var rows [][]string
125 if got := TagsJSON(rows); got != "[]" {
126 t.Fatalf("TagsJSON(empty) = %s", got)
127 }
128 }
129
130 // A quote in a tag value must be escaped, not emitted raw and not dropped.
131 func TestTagsJSONEscapes(t *testing.T) {
132 rows := [][]string{[]string{"title", "a\"b"}}
133 want := "[[\"title\",\"a\\\"b\"]]"
134 if got := TagsJSON(rows); got != want {
135 t.Fatalf("TagsJSON = %s, want %s", got, want)
136 }
137 }
138
139 // Tags carried on an event and parsed back must survive the reader with every
140 // field it keeps.
141 func TestTrackFromCore(t *testing.T) {
142 ev := &nostr.Event{
143 Kind: uint32(protocol.KindTrack),
144 Tags: toCoreTags(fixtureTags()),
145 Content: "Liner notes.",
146 }
147 got, err := TrackFromCore(ev)
148 if err != nil {
149 t.Fatalf("TrackFromCore: %s", err.Error())
150 }
151 if got.D != "PT-ABC-26-00001" {
152 t.Fatalf("d = %q", got.D)
153 }
154 if got.Title != "Song Title" {
155 t.Fatalf("title = %q", got.Title)
156 }
157 if len(got.Artists) != 1 || got.Artists[0] != "Band Name" {
158 t.Fatalf("artists = %d", len(got.Artists))
159 }
160 if len(got.Genres) != 1 || got.Genres[0] != "post-punk" {
161 t.Fatalf("genres = %d", len(got.Genres))
162 }
163 if got.Album == nil || got.Album.Kind != protocol.KindAlbum || got.Album.D != "album-d" {
164 t.Fatalf("album reference lost")
165 }
166 if len(got.Encodings) != 1 || got.Encodings[0].Sha256 != fxFlac || got.Encodings[0].Spec != "flac-24bit-96kHz" {
167 t.Fatalf("encodings = %d", len(got.Encodings))
168 }
169 if got.Master != fxFlac {
170 t.Fatalf("master = %q", got.Master)
171 }
172 if got.Price == nil || got.Price.Amount != "500" || got.Price.Unit != "sat" {
173 t.Fatalf("price lost")
174 }
175 if got.Content != "Liner notes." {
176 t.Fatalf("content = %q", got.Content)
177 }
178 }
179
180 // The d tag is the identity of an addressable kind; a payload without one is
181 // rejected by Validate, and the reader must surface that, not return a track.
182 func TestTrackFromCoreRejectsMissingD(t *testing.T) {
183 ev := &nostr.Event{
184 Kind: uint32(protocol.KindTrack),
185 Tags: [][]string{[]string{"title", "No D"}},
186 }
187 _, err := TrackFromCore(ev)
188 if err == nil {
189 t.Fatal("a track event with no d tag passed validation")
190 }
191 }
192
193 // A reader asked for one kind must refuse another; the caller gets no struct.
194 func TestTrackFromCoreRejectsWrongKind(t *testing.T) {
195 ev := &nostr.Event{Kind: uint32(protocol.KindAlbum)}
196 v, err := TrackFromCore(ev)
197 if err == nil {
198 t.Fatal("an album event was accepted as a track")
199 }
200 if v != nil {
201 t.Fatal("a kind mismatch returned a track")
202 }
203 }
204
205 // Tags() is what the publish wrappers hand to TagsJSON, and it builds its rows
206 // through several helpers inside the protocol package - the exact shape that
207 // used to come back with every row after the first reading the last row's data
208 // (the return-codec bug fixed in moxie 3eac4b0d: codecEncodeValue's KindSlice
209 // shortcut skipped the element walk during a return, so rows pushed into a
210 // caller-owned backing kept pointing at the callee's released frame). Three
211 // rows that must differ is the assertion that catches it, and it is the reason
212 // this test exists rather than only the independent fixture above.
213 func TestTrackTagsReadsItsOwnRows(t *testing.T) {
214 tr := &protocol.Track{D: "PT-ABC-26-00001", Title: "Song Title", Artists: []string{"Band Name"}}
215 tags := tr.Tags()
216 if len(tags) < 3 {
217 t.Fatalf("expected at least 3 rows from Tags(), got %d", len(tags))
218 }
219 if tags[0][0] != protocol.TagD || tags[0][1] != "PT-ABC-26-00001" {
220 t.Fatalf("row 0 = %q %q", tags[0][0], tags[0][1])
221 }
222 if tags[1][0] != protocol.TagTitle || tags[1][1] != "Song Title" {
223 t.Fatalf("row 1 = %q %q", tags[1][0], tags[1][1])
224 }
225 if tags[2][0] != protocol.TagArtist || tags[2][1] != "Band Name" {
226 t.Fatalf("row 2 = %q %q (row 1 was %q %q)", tags[2][0], tags[2][1], tags[1][0], tags[1][1])
227 }
228 // The rows must be distinct: an aliased list passes a first-row check and
229 // fails here, because every row reads the last one written.
230 if tags[1][1] == tags[2][1] {
231 t.Fatal("row 1 and row 2 read the same data")
232 }
233 }
234