commerce_test.mx raw
1 package protocol
2
3 import "testing"
4
5 // The vinyl product from FEATURES_MERCHANDISE.md, verbatim as content JSON.
6 const docProductJSON = `{"id":"album-vinyl-first-press","stall_id":"band-name-releases","name":"Album Name — Vinyl (First Pressing)","description":"180g black vinyl in gatefold sleeve. Mastered for vinyl by [tagged engineer].","images":["https://blossom.musiquay.com/hash"],"currency":"EUR","price":28.00,"quantity":200,"specs":[["format","vinyl"],["weight","180g"],["color","black"],["sleeve","gatefold"],["catalog","ABC-001"],["a","32216:` + labelPub + `:edition-d"],["a","32211:` + ownerPub + `:album-d"]],"shipping":[{"id":"eu","cost":6.00},{"id":"worldwide","cost":15.00}]}`
7
8 func TestParseProduct(t *testing.T) {
9 p, err := ParseProduct(docProductJSON)
10 if err != nil {
11 t.Fatalf("parse: %s", err.Error())
12 }
13 if verr := p.Validate(); verr != nil {
14 t.Fatalf("validate: %s", verr.Error())
15 }
16 if p.ID != "album-vinyl-first-press" || p.StallID != "band-name-releases" {
17 t.Fatalf("ids = %q %q", p.ID, p.StallID)
18 }
19 if p.Currency != "EUR" || p.Price != "28.00" || p.Quantity != "200" {
20 t.Fatalf("money fields: currency=%q price=%q quantity=%q", p.Currency, p.Price, p.Quantity)
21 }
22 if len(p.Images) != 1 || p.Images[0] != "https://blossom.musiquay.com/hash" {
23 t.Fatalf("images = %s", joinWarnings(p.Images))
24 }
25 if len(p.Specs) != 7 || p.Specs[0].Key != "format" || p.Specs[0].Value != "vinyl" {
26 t.Fatalf("specs = %d", len(p.Specs))
27 }
28 if len(p.Shipping) != 2 || p.Shipping[0].ID != "eu" || p.Shipping[0].Cost != "6.00" {
29 t.Fatalf("shipping = %d", len(p.Shipping))
30 }
31 // Physical music carries its provenance inside the specs, as two a keys.
32 refs := p.EditionRefs()
33 if len(refs) != 2 {
34 t.Fatalf("edition refs = %d", len(refs))
35 }
36 if refs[0].Kind != KindEdition || refs[0].D != "edition-d" {
37 t.Fatalf("first ref = %v", refs[0])
38 }
39 if refs[1].Kind != KindAlbum || refs[1].D != "album-d" {
40 t.Fatalf("second ref = %v", refs[1])
41 }
42 if p.Content != docProductJSON {
43 t.Fatal("the raw content was not kept")
44 }
45 }
46
47 // The record-shop stock item: a used copy with condition specs.
48 func TestParseProductStockItem(t *testing.T) {
49 const stock = `{"id":"stock-item-12345","stall_id":"loja-do-som-vinyl","name":"Artist Name — Album Title","description":"Original 1st pressing. Sleeve VG+, media NM. Includes insert.","images":["https://blossom.musiquay.com/hash"],"currency":"EUR","price":35.00,"quantity":1,"specs":[["format","vinyl"],["condition-media","NM"],["condition-sleeve","VG+"],["pressing","1st pressing, 1987"],["catalog","SST-065"],["includes","insert"],["a","32211:` + ownerPub + `:album-d"],["a","32216:` + labelPub + `:edition-d"]],"shipping":[{"id":"local-pickup","cost":0},{"id":"pt","cost":4.00},{"id":"eu","cost":8.00}]}`
50 p, err := ParseProduct(stock)
51 if err != nil {
52 t.Fatalf("parse: %s", err.Error())
53 }
54 if verr := p.Validate(); verr != nil {
55 t.Fatalf("validate: %s", verr.Error())
56 }
57 if len(p.Specs) != 8 {
58 t.Fatalf("specs = %d", len(p.Specs))
59 }
60 if p.Specs[1].Key != "condition-media" || p.Specs[1].Value != "NM" {
61 t.Fatalf("condition = %+v", p.Specs[1])
62 }
63 if p.Shipping[0].Cost != "0" {
64 t.Fatalf("zero cost = %q", p.Shipping[0].Cost)
65 }
66 if refs := p.EditionRefs(); len(refs) != 2 || refs[0].Kind != KindAlbum {
67 t.Fatalf("refs = %d", len(refs))
68 }
69 // Every condition value the doc lists parses as a plain spec value.
70 for _, c := range []string{"Mint", "NM", "VG+", "VG", "G+", "G", "Fair", "Poor"} {
71 prod := p.Copy()
72 prod.Specs[1].Value = c
73 if prod.Specs[1].Value != c {
74 t.Fatalf("condition %q did not survive the copy", c)
75 }
76 }
77 }
78
79 func TestProductJSONRoundTrip(t *testing.T) {
80 p, err := ParseProduct(docProductJSON)
81 if err != nil {
82 t.Fatal(err.Error())
83 }
84 rendered := p.JSON()
85 back, err := ParseProduct(rendered)
86 if err != nil {
87 t.Fatalf("re-parse: %s", err.Error())
88 }
89 if back.ID != p.ID || back.Name != p.Name || back.Description != p.Description {
90 t.Fatalf("identity changed: %+v", back)
91 }
92 if back.Price != p.Price || back.Currency != p.Currency || back.Quantity != p.Quantity {
93 t.Fatalf("money changed: price=%q currency=%q quantity=%q", back.Price, back.Currency, back.Quantity)
94 }
95 if len(back.Specs) != len(p.Specs) || len(back.Shipping) != len(p.Shipping) {
96 t.Fatalf("specs/shipping changed: %d %d", len(back.Specs), len(back.Shipping))
97 }
98 if back.Specs[5].Key != "a" || back.Specs[5].Value != p.Specs[5].Value {
99 t.Fatalf("edition spec changed: %+v", back.Specs[5])
100 }
101 if len(back.EditionRefs()) != 2 {
102 t.Fatal("the edition refs did not survive a render")
103 }
104 }
105
106 func TestProductValidateRejects(t *testing.T) {
107 p, err := ParseProduct(docProductJSON)
108 if err != nil {
109 t.Fatal(err.Error())
110 }
111 bad := p.Copy()
112 bad.StallID = ""
113 if verr := bad.Validate(); verr == nil {
114 t.Fatal("a product with no stall was accepted")
115 }
116 bad2 := p.Copy()
117 bad2.Price = ""
118 if verr := bad2.Validate(); verr == nil {
119 t.Fatal("a product with no price was accepted")
120 }
121 bad3 := p.Copy()
122 bad3.Specs[0].Key = ""
123 if verr := bad3.Validate(); verr == nil {
124 t.Fatal("a spec with no key was accepted")
125 }
126 if _, perr := ParseProduct("[]"); perr == nil {
127 t.Fatal("a non-object product content was accepted")
128 }
129 // Parsing is not validation: a parseable payload still has to satisfy the
130 // fields a checkout needs.
131 st, serr := ParseStall(`{"id":"s"}`)
132 if serr != nil {
133 t.Fatalf("parse stall: %s", serr.Error())
134 }
135 if verr := st.Validate(); verr == nil {
136 t.Fatal("a stall with no name passed validation")
137 }
138 }
139
140 func TestStallJSON(t *testing.T) {
141 s := &Stall{
142 ID: "band-name-merch", Name: "Band Name Merch", Currency: "EUR",
143 }
144 s.Shipping = push(s.Shipping, ShippingZone{ID: "eu", Cost: "5.00"})
145 s.Shipping = push(s.Shipping, ShippingZone{ID: "worldwide", Cost: "12.00"})
146 if verr := s.Validate(); verr != nil {
147 t.Fatalf("validate: %s", verr.Error())
148 }
149 rendered := s.JSON()
150 back, err := ParseStall(rendered)
151 if err != nil {
152 t.Fatalf("re-parse: %s", err.Error())
153 }
154 if back.ID != s.ID || back.Name != s.Name || back.Currency != s.Currency {
155 t.Fatalf("stall changed: %+v", back)
156 }
157 if len(back.Shipping) != 2 || back.Shipping[1].Cost != "12.00" {
158 t.Fatalf("shipping changed: %d", len(back.Shipping))
159 }
160 }
161
162 // The resale listing from FEATURES_MERCHANDISE.md: NIP-99 tags plus the edition
163 // address for provenance.
164 func TestListingRoundTrip(t *testing.T) {
165 var want [][]string
166 want = appendKV(want, TagD, "resale-album-vinyl-001")
167 want = appendKV(want, TagTitle, "Artist Name — Album Title (Vinyl, 1st Pressing)")
168 want = appendKV(want, TagSummary, "NM/VG+. Original 1st pressing with insert.")
169 want = appendKV(want, TagPublishedAt, "1740000000")
170 want = appendKV(want, TagLocation, "Porto, Portugal")
171 want = appendKV(want, TagG, "ez3qe")
172 want = appendTag(want, (&Price{Amount: "45", Unit: "EUR"}).Tag())
173 want = appendKV(want, TagT, "vinyl")
174 want = appendKV(want, TagT, "resale")
175 want = appendKV(want, TagT, "post-punk")
176 want = appendKV(want, TagT, "1st-pressing")
177 want = appendKV(want, TagImage, "https://blossom.musiquay.com/"+coverHash)
178 want = appendKV(want, TagStatus, StatusActive)
179 want = appendTag(want, (&Address{Kind: KindEdition, Pubkey: labelPub, D: "edition-d", Relay: "wss://relay.musiquay.com"}).Tag())
180
181 l, err := ParseListing(want, "Selling my copy.")
182 if err != nil {
183 t.Fatalf("parse: %s", err.Error())
184 }
185 if verr := l.Validate(); verr != nil {
186 t.Fatalf("validate: %s", verr.Error())
187 }
188 if l.Price == nil || l.Price.Amount != "45" || l.Price.Unit != "EUR" {
189 t.Fatalf("price = %v", l.Price)
190 }
191 if l.Edition == nil || l.Edition.Kind != KindEdition {
192 t.Fatalf("edition = %v", l.Edition)
193 }
194 if got := l.Tags(); !sameTags(got, want) {
195 t.Fatalf("round trip differs:\n got %s\nwant %s", joinTags(got), joinTags(want))
196 }
197 // A provenance reference of the wrong kind is rejected.
198 bad := &Listing{D: "d", Title: "t"}
199 bad.Edition = &Address{Kind: KindTrack, Pubkey: ownerPub, D: "x"}
200 if verr := bad.Validate(); verr == nil {
201 t.Fatal("a provenance reference to a track was accepted")
202 }
203 }
204
205 // The service listing from FEATURES_MARKETPLACE.md: a frequency-priced price
206 // tag and t-tag conventions.
207 func TestServiceListingRoundTrip(t *testing.T) {
208 var want [][]string
209 want = appendKV(want, TagD, "foh-weekends")
210 want = appendKV(want, TagTitle, "FOH Engineer Available — Weekend Festivals, Lisbon Area")
211 want = appendKV(want, TagSummary, "10+ years mixing live rock/metal. Own console. Weekend availability.")
212 want = appendKV(want, TagPublishedAt, "1735851600")
213 want = appendKV(want, TagLocation, "Lisbon, Portugal")
214 want = appendKV(want, TagG, "eyckc")
215 want = appendTag(want, (&Price{Amount: "400", Unit: "EUR", Period: "day"}).Tag())
216 want = appendKV(want, TagT, "sound-engineer")
217 want = appendKV(want, TagT, "foh")
218 want = appendKV(want, TagT, "available")
219 want = appendKV(want, TagT, "festival")
220 want = appendKV(want, TagImage, "https://blossom.musiquay.com/"+coverHash)
221 want = appendKV(want, TagStatus, StatusActive)
222
223 l, err := ParseListing(want, "Full description in markdown.")
224 if err != nil {
225 t.Fatalf("parse: %s", err.Error())
226 }
227 if verr := l.Validate(); verr != nil {
228 t.Fatalf("validate: %s", verr.Error())
229 }
230 if l.Price == nil || l.Price.Period != "day" {
231 t.Fatalf("price = %v", l.Price)
232 }
233 if got := l.Tags(); !sameTags(got, want) {
234 t.Fatalf("round trip differs:\n got %s\nwant %s", joinTags(got), joinTags(want))
235 }
236 }
237
238 func TestJSONHelpers(t *testing.T) {
239 obj := `{"a":"x","b":[1,2,{"c":"d"}],"e":null,"f":"has \" quote"}`
240 if v := jsonFieldString(obj, "a"); v != "x" {
241 t.Fatalf("a = %q", v)
242 }
243 if v := jsonFieldRaw(obj, "b"); v != `[1,2,{"c":"d"}]` {
244 t.Fatalf("b = %q", v)
245 }
246 if v := jsonFieldRaw(obj, "e"); v != "null" {
247 t.Fatalf("e = %q", v)
248 }
249 if v := jsonFieldString(obj, "f"); v != `has " quote` {
250 t.Fatalf("f = %q", v)
251 }
252 if v := jsonFieldString(obj, "zz"); v != "" {
253 t.Fatalf("missing field = %q", v)
254 }
255 els := jsonElements(`[1, [2,3], "four", {"five":5}]`)
256 if len(els) != 4 || els[1] != "[2,3]" || els[2] != `"four"` || els[3] != `{"five":5}` {
257 t.Fatalf("elements = %s", joinWarnings(els))
258 }
259 if !isJSONObject(` {"a":1} `) {
260 t.Fatal("a leading space broke object detection")
261 }
262 if isJSONObject("[1]") {
263 t.Fatal("an array was reported as an object")
264 }
265 }
266