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