render_test.gno
44.24 Kb · 959 lines
1package bolo
2
3import (
4 "chain"
5 "math"
6 "strconv"
7 "strings"
8 "testing"
9
10 "gno.land/p/nt/markdown/sanitize/v0"
11 "gno.land/p/nt/seqid/v0"
12 "gno.land/p/nt/testutils/v0"
13)
14
15func TestGnot(t *testing.T) {
16 for ugnot, want := range map[int64]string{
17 0: "0 GNOT", 1: "0.000001 GNOT", 1_500_000: "1.5 GNOT", 5_000_000: "5 GNOT",
18 1_234_567: "1.234567 GNOT", 10_000_010: "10.00001 GNOT", -2_500_000: "-2.5 GNOT",
19 } {
20 if got := gnot(ugnot); got != want {
21 t.Errorf("gnot(%d) = %q, want %q", ugnot, got, want)
22 }
23 }
24 if got := when(0); got != "1970-01-01 00:00 UTC" {
25 t.Errorf("when(0) = %q", got)
26 }
27 for n, want := range map[int]string{0: "0 donors", 1: "1 donor", 2: "2 donors"} {
28 if got := count(n, "donor", "donors"); got != want {
29 t.Errorf("count(%d) = %q, want %q", n, got, want)
30 }
31 }
32}
33
34func TestParsers(t *testing.T) {
35 for s, want := range map[string]int{"1": 1, "20": 20, "999999999": 999999999} {
36 if n, ok := parsePage(s); !ok || n != want {
37 t.Errorf("parsePage(%q) = %d, %t", s, n, ok)
38 }
39 }
40 for _, s := range []string{"", "0", "01", "-1", "+1", "1x", " 1", "1000000000"} {
41 if _, ok := parsePage(s); ok {
42 t.Errorf("parsePage(%q) accepted", s)
43 }
44 }
45 good := testutils.TestAddress("renderer").String()
46 if _, ok := parseAddress(good); !ok {
47 t.Errorf("parseAddress refused %s", good)
48 }
49 last := "q"
50 if good[39:] == "q" {
51 last = "p"
52 }
53 badSum := good[:39] + last // 40 characters, a lowercase "g", a wrong checksum
54 for _, s := range []string{"", "notanaddress", strings.ToUpper(good), good[:39], good + "x", badSum} {
55 if _, ok := parseAddress(s); ok {
56 t.Errorf("parseAddress(%q) accepted", s)
57 }
58 }
59 if q, ok := parseQuery("page=2&&size=1000000&x"); !ok || q["page"] != "2" || q["size"] != "1000000" {
60 t.Errorf("parseQuery: %v, %t", q, ok)
61 }
62 if _, ok := parseQuery("page=1&page=2"); ok {
63 t.Errorf("parseQuery accepted a key twice")
64 }
65}
66
67// TestRenderNotFound: every path that fails gets the one not-found page, which echoes
68// nothing from the request (API §5).
69func TestRenderNotFound(t *testing.T) {
70 c := testCampaign(9961, modeEscrow, 0)
71 id, unstore := storeCampaign(c)
72 defer unstore()
73 c.receipts.Set(seqid.ID(1).Binary(), &receipt{seq: 1, donor: testutils.TestAddress("donor"), amount: 5})
74 bad := []string{
75 "c/0" + id, "c/x", "c/" + id + "/", "c/" + id + "/r/x", "c/" + id + "/r/0", "c/" + id + "/r/2",
76 "c/" + id + "/donations?page=-1", "c/" + id + "/donations?page=0", "c/" + id + "/ledger?page=x",
77 "c/" + id + "/updates?page=1&page=2", "c/" + id + "/nope", "c/99999", "c/", "c/" + id + "/r/1/x",
78 "u/notanaddress", "u/" + strings.ToUpper(testutils.TestAddress("renderer").String()),
79 "u/" + testutils.TestAddress("renderer").String() + "/nope",
80 "u/" + testutils.TestAddress("renderer").String() + "/gave?after=0x",
81 "?after=x", "?after=", "?cat=nope", "?cat=", "c?cat=nope", "c/" + id + "/r?page=0", "u/", "u?address=a&address=b",
82 "about/x", "x", "/", "//", strings.Repeat("c/", 200),
83 }
84 for _, path := range bad {
85 if got := Render(path); got != notFound {
86 t.Errorf("Render(%q) isn't the not-found page:\n%s", path, got)
87 }
88 }
89 // A key the route doesn't take is ignored.
90 if Render("c/"+id+"/donations?size=1000000") == notFound || Render("?page=-1") == notFound {
91 t.Errorf("an ignored key made the page fail")
92 }
93}
94
95// injection is what a stranger, a moderator or an organizer might write to pass for
96// Bolo, plant a link, an image, raw HTML or a mention, open an alert or one of
97// gnoweb's blocks, hide characters, or break out of their slot — shapes the writes'
98// own checks let through, the hidden characters stripped (API §2.3).
99const injection = "[!WARNING] [Verify](https://evil.example) **BOLO** # x | y ] ) ! <b>raw</b>  \u200b\u202e @bolo_help </gno-columns> <gno-columns-sep> <gno-form path=\"u\">"
100
101// mention reports whether s holds what gnoweb makes a link of on its own: an "@" no
102// backslash escapes, or an address after a space or opening a line's text.
103func mention(s string) bool {
104 for i := 0; i < len(s); i++ {
105 if s[i] == '@' && (i == 0 || s[i-1] != '\\') {
106 return true
107 }
108 }
109 for _, line := range strings.Split(s, "\n") {
110 if strings.HasPrefix(strings.TrimLeft(line, "#>- "), "g1") {
111 return true
112 }
113 }
114 return strings.Contains(s, " g1")
115}
116
117// ownTags reports whether every tag of gnoweb's blocks in s that isn't escaped stands
118// on a line of its own, as Bolo writes them — the only place gnoweb reads one — and the
119// columns open as often as they close.
120func ownTags(s string) bool {
121 lines := map[string]bool{"<gno-columns>": true, "<gno-columns-sep />": true, "</gno-columns>": true,
122 "<gno-form path=\"u\">": true, " <gno-input name=\"address\" placeholder=\"g1…\" />": true, "</gno-form>": true}
123 for _, line := range strings.Split(s, "\n") {
124 own := lines[line] || line == " <gno-input name=\"amount\" placeholder=\"7.5\" />" ||
125 strings.HasPrefix(line, "<gno-form path=\"c/") && strings.HasSuffix(line, "/give\">") && isDigits(line[len("<gno-form path=\"c/"):len(line)-len("/give\">")])
126 for i := 0; i+5 <= len(line); i++ {
127 if (line[i:i+5] == "<gno-" || line[i:i+5] == "</gno") && (i == 0 || line[i-1] != '\\') && !own {
128 return false
129 }
130 }
131 }
132 return strings.Count(s, "\n<gno-columns>\n") == strings.Count(s, "\n</gno-columns>\n")
133}
134
135// isDigits reports whether s is one or more decimal digits.
136func isDigits(s string) bool {
137 for i := 0; i < len(s); i++ {
138 if s[i] < '0' || s[i] > '9' {
139 return false
140 }
141 }
142 return s != ""
143}
144
145// TestRenderEscapes: every text slot is escaped by who wrote it. Strangers' and
146// moderators' text can carry no link, no image, no mention and none of gnoweb's
147// blocks; the organizer's story and updates stay inside their quote, below Bolo's own
148// blocks; the alerts are Bolo's — the notice on the campaign's page, the refund banner
149// on a refunded gift's receipt — which no user text can open; addresses sit in code
150// spans (API §5).
151func TestRenderEscapes(t *testing.T) {
152 phisher := testutils.TestAddress("phisher").String()
153 inj := phisher + " " + injection + " " + phisher
154 story := "[!WARNING] Notice from Bolo's moderator\nHelp us.\n## Check it yourself\n- Escrow: `g1fake`\n| a | b |\n[ok](https://ok.example) @bolo_help"
155 body := "## Act\n[Give 5 GNOT](https://evil.example)"
156 donor := testutils.TestAddress("donor")
157 c := testCampaign(9962, modeEscrow, 0)
158 c.text.title, c.text.description = inj, story
159 c.text.imageCID, c.text.imageAlt = "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG", inj
160 c.state.notice = inj
161 id, unstore := storeCampaign(c)
162 defer unstore()
163 c.receipts.Set(seqid.ID(1).Binary(), &receipt{seq: 1, donor: donor, amount: 5, message: inj})
164 c.state.donationCount, c.state.recent[0], c.state.recentNext = 1, 1, 1
165 c.updates.Set(seqid.ID(1).Binary(), &update{seq: 1, body: body})
166 c.state.updateCount = 1
167 c.contributions.Set(donor.String(), &contribution{amount: 5, refunded: 1, public: true})
168 c.refunds.Set(seqid.ID(1).Binary(), &refund{seq: 1, amount: 1, donor: donor, kind: "open"})
169 c.withdrawals.Set(seqid.ID(1).Binary(), &withdrawal{seq: 1, amount: 1, to: c.beneficiary})
170 byOrganizer.Set(c.organizer.String()+"/"+c.id.Binary(), true)
171 defer byOrganizer.Remove(c.organizer.String() + "/" + c.id.Binary())
172
173 org := "u/" + c.organizer.String()
174 for _, path := range []string{"c/" + id, "c/" + id + "/r/1", "c/" + id + "/donations", "c/" + id + "/updates", "c/" + id + "/ledger", "c/" + id + "/give", "c/" + id + "/give?amount=5", "?cat=water", org, org + "/organized"} {
175 out := Render(path)
176 // The organizer's own text, quoted, keeps its links and mentions: without it,
177 // nothing on the page may carry one.
178 rest := strings.ReplaceAll(strings.ReplaceAll(out, sanitize.Blockquote(story), ""), sanitize.Blockquote(body), "")
179 if strings.Contains(rest, "](https://") || mention(rest) {
180 t.Errorf("%s: a link, an image or a mention outside the organizer's quote:\n%s", path, rest)
181 }
182 if strings.Contains(strings.ReplaceAll(rest, `\<`, ""), "<b>") || strings.ContainsAny(rest, "\u200b\u202e") {
183 t.Errorf("%s: raw HTML or a hidden character outside the organizer's quote:\n%s", path, rest)
184 }
185 if !ownTags(rest) {
186 t.Errorf("%s: a block's tag Bolo didn't write:\n%s", path, rest)
187 }
188 alerts, entities := 0, 0
189 switch path {
190 case "c/" + id:
191 alerts, entities = 1, 2
192 if !strings.HasPrefix(out, "> [!WARNING] Notice from Bolo's moderator\n> ") {
193 t.Errorf("the notice isn't the alert at the top of the page:\n%s", out)
194 }
195 case "c/" + id + "/r/1":
196 alerts = 1
197 if !strings.HasPrefix(out, "> [!CAUTION] Refunded since\n> 0.000001 GNOT went back to this donor after this gift.\n\n# Gift receipt") {
198 t.Errorf("the refund banner isn't the alert at the top of the receipt:\n%s", out)
199 }
200 }
201 if n := strings.Count("\n"+out, "\n> [!"); n != alerts {
202 t.Errorf("%s: %d alerts, want %d", path, n, alerts)
203 }
204 // The title's heading and the notice's line open with an address; nothing
205 // else may write its "g" as an entity, which a table of contents shows as is.
206 if n := strings.Count(out, "g"); n != entities {
207 t.Errorf("%s: %d entities, want %d", path, n, entities)
208 }
209 }
210 page := Render("c/" + id)
211 verifyAt, storyAt := strings.Index(page, "## Check it yourself\n\n- **Escrow** `g1"), strings.Index(page, "## In the organizer's words")
212 if verifyAt < 0 || storyAt < 0 || verifyAt > storyAt {
213 t.Errorf("the verification block isn't before the story: %d, %d", verifyAt, storyAt)
214 }
215 if !strings.Contains(page, "## In the organizer's words\n"+sanitize.Blockquote(story)) {
216 t.Errorf("the story isn't quoted under its heading:\n%s", page)
217 }
218 if !strings.Contains(page, "](ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG)") {
219 t.Errorf("the photo is missing:\n%s", page)
220 }
221}
222
223// TestRenderHiddenPhoto: a hidden photo is gone from the campaign's page, its
224// description with it (API §3.1, §3.5, §5).
225func TestRenderHiddenPhoto(t *testing.T) {
226 c := testCampaign(9968, modeEscrow, 0)
227 c.text.imageCID, c.text.imageAlt = "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG", "The village well"
228 id, unstore := storeCampaign(c)
229 defer unstore()
230 if page := Render("c/" + id); !strings.Contains(page, " {
231 t.Errorf("the photo is missing:\n%s", page)
232 }
233 c.state.imageHidden = true
234 if page := Render("c/" + id); strings.Contains(page, "ipfs://") || strings.Contains(page, "The village well") {
235 t.Errorf("a hidden photo is still on the page:\n%s", page)
236 }
237}
238
239// TestRenderDelisted: a delisted campaign shows its notice and no donate links, and is
240// on no list that promotes (API §5).
241func TestRenderDelisted(t *testing.T) {
242 org := testutils.TestAddress("renderorg")
243 cs, unindex := indexCampaigns(9963, 2, org, org)
244 defer unindex()
245 off := cs[0]
246 off.state.listed, off.state.notice = false, "Under review"
247 dropActive(off)
248 defer addActive(off)
249 if page := Render("c/9963"); !strings.Contains(page, "Under review") || strings.Contains(page, "## Give") || strings.Contains(page, "func=Donate&id=9963&tip") {
250 t.Errorf("a delisted campaign's page:\n%s", page)
251 }
252 if !strings.Contains(Render("c/9964"), "## Give") {
253 t.Errorf("a listed campaign has no donate links")
254 }
255 for _, path := range []string{"u/" + org.String(), "u/" + org.String() + "/organized"} {
256 if addr := Render(path); strings.Contains(addr, ":c/9963)") || !strings.Contains(addr, ":c/9964)") {
257 t.Errorf("%s lists the delisted campaign, or misses the listed one:\n%s", path, addr)
258 }
259 }
260 if home := Render("?cat=animals"); strings.Contains(home, ":c/9963)") || !strings.Contains(home, ":c/9964)") {
261 t.Errorf("the home page:\n%s", home)
262 }
263}
264
265// TestRenderBounds: a path longer than any valid one gets the not-found page before it
266// is parsed, and a page's size never comes from the query: ?size= is ignored (API §5).
267func TestRenderBounds(t *testing.T) {
268 if got := Render("?pad=" + strings.Repeat("a", 300)); got != notFound {
269 t.Errorf("a 305-byte path rendered")
270 }
271 c := testCampaign(9965, modeEscrow, 0)
272 id, unstore := storeCampaign(c)
273 defer unstore()
274 for i := 1; i <= 25; i++ {
275 c.receipts.Set(seqid.ID(i).Binary(), &receipt{seq: uint64(i), donor: testutils.TestAddress("donor"), amount: 5})
276 }
277 if n := strings.Count(Render("c/"+id+"/donations?size=50"), "[receipt]("); n != 20 {
278 t.Errorf("?size=50: %d gifts on the page, want 20", n)
279 }
280 if n := strings.Count(Render("c/"+id+"/donations?page=2"), "[receipt]("); n != 5 {
281 t.Errorf("page 2: %d gifts, want 5", n)
282 }
283}
284
285// TestRenderGiveLinks: the one-click donate links appear only while the campaign is
286// open, listed, and Bolo isn't paused (API §5).
287func TestRenderGiveLinks(t *testing.T) {
288 c := testCampaign(9966, modeEscrow, 0)
289 id, unstore := storeCampaign(c)
290 defer unstore()
291 defer func() { paused = false }()
292 give := func() bool { return strings.Contains(Render("c/"+id), "## Give") }
293 if !give() {
294 t.Errorf("an open, listed campaign has no donate links")
295 }
296 paused = true
297 if give() {
298 t.Errorf("donate links while Bolo is paused")
299 }
300 paused = false
301 c.deadline = now() - 1
302 if give() {
303 t.Errorf("donate links past the deadline")
304 }
305 c.deadline = 0
306 c.state.status = statusClosed
307 if give() {
308 t.Errorf("donate links on a closed campaign")
309 }
310}
311
312// TestRenderActLinks: Withdraw is offered only while the escrow holds coins, and Close
313// whenever someone could close: the organizer while the campaign is open, anyone once
314// CanClose says so (API §5).
315func TestRenderActLinks(t *testing.T) {
316 c := testCampaign(9967, modeEscrow, 0)
317 id, unstore := storeCampaign(c)
318 defer unstore()
319 page := func() string { return Render("c/" + id) }
320 closeBy := func(who string) string { return "func=Close&id=" + id + "): " + who }
321 if p := page(); strings.Contains(p, "[Withdraw](") || !strings.Contains(p, closeBy("the organizer")) {
322 t.Errorf("an empty escrow offers Withdraw, or the organizer can't close:\n%s", p)
323 }
324 testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", 5)))
325 if p := page(); !strings.Contains(p, "[Withdraw](") {
326 t.Errorf("an escrow holding coins offers no Withdraw:\n%s", p)
327 }
328 c.deadline = now() - 1 // passed, not recorded: anyone may close
329 if p := page(); !strings.Contains(p, closeBy("anyone")) {
330 t.Errorf("a passed deadline nobody recorded offers no Close to anyone:\n%s", p)
331 }
332 c.deadline, c.state.status = 0, statusClosed
333 if p := page(); strings.Contains(p, "[Close](") {
334 t.Errorf("a closed campaign offers Close:\n%s", p)
335 }
336 // Withdraw stops when the beneficiary's window ends; Finalize appears when it can.
337 c.state.raised, c.state.withdrawn, c.state.everWithdrawn, c.state.closedAt = 7_000_000, 2_000_000, true, now()
338 testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", 5_000_000))) // the 5 GNOT the books hold
339 if p := page(); !strings.Contains(p, "[Withdraw](") || strings.Contains(p, "[Finalize](") {
340 t.Errorf("closed, the beneficiary's window running: no Withdraw, or a Finalize:\n%s", p)
341 }
342 c.state.closedAt = now() - c.params.ExitDelay
343 if p := page(); strings.Contains(p, "[Withdraw](") || !strings.Contains(p, "func=Finalize&id="+id+"): anyone") {
344 t.Errorf("closed, the beneficiary's window over: a Withdraw, or no Finalize:\n%s", p)
345 }
346}
347
348// TestRenderEmptyPages: a page with nothing to show says so, and says "None on this
349// page." on a later page or when a "More" follows — the home page once its read budget
350// went on passed deadlines, an address list whose newest are all delisted, a campaign's
351// own lists past their first page (API §5).
352func TestRenderEmptyPages(t *testing.T) {
353 org := testutils.TestAddress("emptyorg")
354 if home := Render("?cat=animals"); !strings.Contains(home, "No campaigns here yet.") {
355 t.Errorf("an empty category:\n%s", home)
356 }
357 if later := Render("?cat=animals&after=1"); !strings.Contains(later, "None on this page.") || strings.Contains(later, "[More](") {
358 t.Errorf("a later page with nothing on it:\n%s", later)
359 }
360 cs, unindex := indexCampaigns(9600, 4*listPage+1, org, org)
361 defer unindex()
362 for _, c := range cs {
363 c.deadline = now() - 1
364 }
365 if home := Render("?cat=animals"); !strings.Contains(home, "None on this page.") || !strings.Contains(home, "[More](") {
366 t.Errorf("a page of passed deadlines, with more to read:\n%s", home)
367 }
368 for _, c := range cs {
369 c.state.listed = false
370 }
371 for _, p := range []string{"u/" + org.String(), "u/" + org.String() + "/organized"} {
372 if page := Render(p); !strings.Contains(page, "None on this page.") || !strings.Contains(page, "[More](") {
373 t.Errorf("%s: a page of delisted campaigns, with more to come:\n%s", p, page)
374 }
375 }
376 other := "u/" + testutils.TestAddress("nobody").String() + "/organized"
377 if first, later := Render(other), Render(other+"?after=1"); !strings.Contains(first, "None.") || !strings.Contains(later, "None on this page.") {
378 t.Errorf("an empty address list, first and later pages:\n%s\n%s", first, later)
379 }
380 c := testCampaign(9969, modeEscrow, 0)
381 id, unstore := storeCampaign(c)
382 defer unstore()
383 for route, first := range map[string]string{"donations": "No gifts yet.", "ledger": "None.\n\n## Refunds\n\nNone.", "updates": "No updates yet."} {
384 later := strings.ReplaceAll(strings.ReplaceAll(first, "yet.", "on this page."), "None.", "None on this page.")
385 if p1, p2 := Render("c/"+id+"/"+route), Render("c/"+id+"/"+route+"?page=2"); !strings.Contains(p1, first) || !strings.Contains(p2, later) {
386 t.Errorf("an empty %s list, pages 1 and 2:\n%s\n%s", route, p1, p2)
387 }
388 }
389 var b strings.Builder
390 writeListed(&b, []CampaignView{{Listed: false}}, true)
391 if b.String() != "None on this page.\n\n" {
392 t.Errorf("a list of delisted campaigns with more to come: %q", b.String())
393 }
394 b.Reset()
395 writeListed(&b, nil, false)
396 if b.String() != "None.\n\n" {
397 t.Errorf("an empty list: %q", b.String())
398 }
399}
400
401// giveTo records a public gift of amount ugnot from donor to c, as Donate would for
402// the pages to show: its receipt, the recent gifts, the counts, the donor's
403// contribution, and the donor's list.
404func giveTo(c *campaign, seq uint64, donor address, amount int64) {
405 s := c.state
406 c.receipts.Set(seqid.ID(seq).Binary(), &receipt{seq: seq, donor: donor, amount: amount, at: now(), message: "Courage"})
407 s.recent[s.recentNext%recentSize] = seq
408 s.recentNext++
409 s.donationCount++
410 s.donorCount++
411 s.raised += amount
412 c.contributions.Set(donor.String(), &contribution{amount: amount, public: true})
413 byDonor.Set(donor.String()+"/"+c.id.Binary(), true)
414}
415
416// pageLinks returns the paths of a page's links to Bolo's own pages, and the
417// functions its transaction links call.
418func pageLinks(page string) (paths, funcs []string) {
419 at := "](" + webPath
420 for i := strings.Index(page, at); i >= 0; i = strings.Index(page, at) {
421 page = page[i+len(at):]
422 end := strings.Index(page, ")")
423 target := page[:end]
424 switch {
425 case target == "":
426 paths = append(paths, "")
427 case target[0] == ':':
428 paths = append(paths, target[1:])
429 case strings.HasPrefix(target, "$help&func="):
430 name, _, _ := strings.Cut(strings.TrimPrefix(target, "$help&func="), "&")
431 funcs = append(funcs, name)
432 }
433 }
434 return paths, funcs
435}
436
437// TestRenderLinks: every link a page writes leads to a page, every transaction link
438// calls one of Bolo's writes, and every prefix of every path renders a page too, as
439// gnoweb's header links each one (UI spec §4.13) — on campaigns of each kind.
440func TestRenderLinks(t *testing.T) {
441 org, ben, donor := testutils.TestAddress("linkorg"), testutils.TestAddress("linkben"), testutils.TestAddress("linkdonor")
442 cs, unindex := indexCampaigns(9700, 4, org, ben)
443 defer unindex()
444 open, direct, closed, settled := cs[0], cs[1], cs[2], cs[3]
445 direct.mode = modeDirect
446 closed.state.status, closed.state.closedAt, closed.state.closeReason = statusClosed, now(), reasonOrganizer
447 settled.state.status, settled.state.closedAt, settled.state.closeReason = statusSettled, now(), reasonOrganizer
448 dropActive(closed)
449 dropActive(settled)
450 for _, c := range cs {
451 giveTo(c, 1, donor, 5_000_000)
452 defer byDonor.Remove(donor.String() + "/" + c.id.Binary())
453 }
454 open.updates.Set(seqid.ID(1).Binary(), &update{seq: 1, at: now(), body: "Started."})
455 open.state.updateCount = 1
456 open.withdrawals.Set(seqid.ID(1).Binary(), &withdrawal{seq: 1, amount: 1_000_000, at: now(), to: ben})
457 open.state.withdrawn, open.state.everWithdrawn = 1_000_000, true
458 closed.refunds.Set(seqid.ID(1).Binary(), &refund{seq: 1, amount: 1_000_000, at: now(), donor: donor, kind: windowOpen})
459 closed.state.refunded = 1_000_000
460 for _, c := range cs { // the bank holds what the books say
461 if held := trackedBalance(c); held > 0 {
462 testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", held)))
463 }
464 }
465
466 writes := map[string]bool{"Donate": true, "Refund": true, "Withdraw": true, "Close": true, "Finalize": true, "PostUpdate": true, "CreateCampaign": true}
467 seen := map[string]bool{}
468 queue := []string{"", "about", "params", "u", "u?address=" + donor.String(), "u?address=nope", "c/9700/give?amount=7.5", "c/9700/give?amount=nope"}
469 for _, c := range cs {
470 queue = append(queue, "c/"+idString(c.id)+"/r/1")
471 }
472 for len(queue) > 0 {
473 path := queue[0]
474 queue = queue[1:]
475 if seen[path] {
476 continue
477 }
478 seen[path] = true
479 page := Render(path)
480 if page == notFound {
481 t.Errorf("%q: not found", path)
482 continue
483 }
484 paths, funcs := pageLinks(page)
485 for _, f := range funcs {
486 if !writes[f] {
487 t.Errorf("%q links to a transaction calling %q", path, f)
488 }
489 }
490 route, _, _ := strings.Cut(path, "?")
491 segs := strings.Split(route, "/")
492 for i := 1; i < len(segs); i++ {
493 paths = append(paths, strings.Join(segs[:i], "/"))
494 }
495 queue = append(queue, paths...)
496 }
497 for _, want := range []string{"c", "c/9700", "c/9700/r", "c/9700/give", "c/9700/donations", "c/9700/ledger", "c/9700/updates", "u", "u/" + org.String(), "u/" + ben.String(), "u/" + donor.String()} {
498 if !seen[want] {
499 t.Errorf("no link or prefix reaches %q", want)
500 }
501 }
502}
503
504// TestRenderPrefixes: the prefixes gnoweb's header links render the pages they stand
505// for: "c" the home page, "c/{id}/r" the campaign's gifts (UI spec §4.13).
506func TestRenderPrefixes(t *testing.T) {
507 c := testCampaign(9971, modeEscrow, 0)
508 id, unstore := storeCampaign(c)
509 defer unstore()
510 if Render("c") != Render("") || Render("c?cat=water") != Render("?cat=water") {
511 t.Errorf("c isn't the home page")
512 }
513 if Render("c/"+id+"/r") != Render("c/"+id+"/donations") || Render("c/"+id+"/r?page=2") != Render("c/"+id+"/donations?page=2") {
514 t.Errorf("c/%s/r isn't the gifts page", id)
515 }
516}
517
518// TestRenderLookup: "u" is a form to find an address, which submits back as
519// ?address=: a valid address gets its page, anything else the form again with one
520// fixed line, echoing nothing (API §5).
521func TestRenderLookup(t *testing.T) {
522 form := Render("u")
523 if !strings.Contains(form, "# Find an address") || !strings.Contains(form, "\n<gno-form path=\"u\">\n <gno-input name=\"address\" placeholder=\"g1…\" />\n</gno-form>\n") || strings.Contains(form, "isn't") {
524 t.Errorf("the lookup page:\n%s", form)
525 }
526 good := testutils.TestAddress("lookedup").String()
527 if got := Render("u?address=" + good); got != Render("u/"+good) {
528 t.Errorf("a valid address doesn't get its page:\n%s", got)
529 }
530 for _, bad := range []string{"", "notanaddress", strings.ToUpper(good), good + "x", "%3Cb%3Eecho%3C/b%3E", "<b>echo</b>"} {
531 got := Render("u?address=" + bad)
532 if !strings.Contains(got, "That isn't a gno.land address.") || !strings.Contains(got, "<gno-form") {
533 t.Errorf("u?address=%s: no form, or no line saying why:\n%s", bad, got)
534 }
535 if bad != "" && strings.Contains(got, bad) {
536 t.Errorf("u?address=%s: the page echoes it:\n%s", bad, got)
537 }
538 }
539}
540
541// TestRenderGiveRoom: the donate links offer only what Donate would take — the presets
542// within the room the escrow cap leaves, and the room said — and none, with a banner,
543// when not even the smallest gift fits (API §5).
544func TestRenderGiveRoom(t *testing.T) {
545 c := testCampaign(9972, modeEscrow, 0)
546 id, unstore := storeCampaign(c)
547 defer unstore()
548 saved := escrowCap
549 defer func() { escrowCap = saved }()
550 presetLink := func(page string, ugnot int64) bool {
551 return strings.Contains(page, "&.send="+strconv.FormatInt(ugnot, 10)+"ugnot&")
552 }
553 escrowCap = 30_000_000
554 c.state.raised = 12_000_000 // 18 GNOT of room
555 page := Render("c/" + id)
556 if !presetLink(page, 5_000_000) || !presetLink(page, 10_000_000) || presetLink(page, 20_000_000) || presetLink(page, 50_000_000) {
557 t.Errorf("presets past the room, or none within it:\n%s", page)
558 }
559 if !strings.Contains(page, "[Another amount](/r/g12yg9nh4ncma44emgm8msxe8aavzywt0p95tanv/bolo/v0:c/"+id+"/give)") || !strings.Contains(page, "It can take 18 GNOT more for now") {
560 t.Errorf("no other amount, or the room isn't said:\n%s", page)
561 }
562 c.state.raised = 29_500_000 // half a GNOT: less than the smallest gift
563 page = Render("c/" + id)
564 if strings.Contains(page, "## Give") || !strings.Contains(page, "> [!NOTE] Full for now\n") {
565 t.Errorf("donate links with no room, or no banner:\n%s", page)
566 }
567 escrowCap = 0
568 if page = Render("c/" + id); !presetLink(page, 50_000_000) || strings.Contains(page, "It can take") {
569 t.Errorf("no cap, yet a preset left out or a room said:\n%s", page)
570 }
571 escrowCap = 30_000_000
572 c.mode = modeDirect // the cap is escrow's
573 if page = Render("c/" + id); !presetLink(page, 50_000_000) || strings.Contains(page, "Full for now") {
574 t.Errorf("a direct campaign held to the escrow cap:\n%s", page)
575 }
576 // The room is the cap less the tracked balance, never the bank's, and a preset
577 // under the smallest gift is left out too.
578 c.mode = modeEscrow
579 c.state.raised, c.state.withdrawn, c.state.refunded, c.state.everWithdrawn = 40_000_000, 20_000_000, 2_000_000, true // 18 GNOT tracked
580 testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", 27_000_000))) // and 9 sent outside Bolo
581 page = Render("c/" + id)
582 if !presetLink(page, 5_000_000) || !presetLink(page, 10_000_000) || presetLink(page, 20_000_000) || !strings.Contains(page, "It can take 12 GNOT more for now") {
583 t.Errorf("the room isn't the cap less the tracked balance:\n%s", page)
584 }
585 savedMin := minDonation
586 minDonation = 7_000_000
587 if page = Render("c/" + id); presetLink(page, 5_000_000) || !presetLink(page, 10_000_000) {
588 t.Errorf("a preset under the smallest gift:\n%s", page)
589 }
590 minDonation = savedMin
591 escrowCap = 23_000_000 // exactly 5 GNOT of room
592 if page = Render("c/" + id); !presetLink(page, 5_000_000) || presetLink(page, 10_000_000) {
593 t.Errorf("a preset equal to the room left out:\n%s", page)
594 }
595 escrowCap = 19_000_000 // room for exactly the smallest gift
596 if page = Render("c/" + id); !strings.Contains(page, "## Give") || presetLink(page, 5_000_000) || !strings.Contains(page, "It can take 1 GNOT more for now") {
597 t.Errorf("room for exactly the smallest gift:\n%s", page)
598 }
599}
600
601// TestRenderBanner: the campaign's page shows the one state banner the UI spec's state
602// matrix gives its state (§6), and none while nothing is out of the ordinary.
603func TestRenderBanner(t *testing.T) {
604 c := testCampaign(9973, modeEscrow, 0)
605 id, unstore := storeCampaign(c)
606 defer unstore()
607 defer func() { paused = false }()
608 banner := func() string {
609 page := Render("c/" + id)
610 if n := strings.Count("\n"+page, "\n> [!"); n > 1 {
611 t.Errorf("%d banners:\n%s", n, page)
612 }
613 i := strings.Index(page, "> [!")
614 if i < 0 {
615 return ""
616 }
617 return page[i : i+strings.Index(page[i:], "\n\n")]
618 }
619 if b := banner(); b != "" {
620 t.Errorf("an open campaign shows a banner: %q", b)
621 }
622 paused = true
623 if b := banner(); !strings.HasPrefix(b, "> [!CAUTION] Bolo is paused\n") {
624 t.Errorf("paused: %q", b)
625 }
626 paused = false
627 saved, savedMin := escrowCap, minDonation
628 escrowCap = 500_000 // below the smallest gift: no withdrawal makes room
629 if b := banner(); b != "> [!NOTE] Full for now\n> Bolo's escrow cap is set below its smallest gift, so no campaign in escrow can take a gift for now." {
630 t.Errorf("a cap below the smallest gift: %q", b)
631 }
632 escrowCap, c.state.raised = 2_000_000, 1_500_000 // half a GNOT of room, under the smallest gift
633 if b := banner(); b != "> [!NOTE] Full for now\n> This campaign can't take more until the beneficiary withdraws." {
634 t.Errorf("a full escrow: %q", b)
635 }
636 escrowCap = minDonation // a cap of exactly the smallest gift: a withdrawal makes room
637 if b := banner(); b != "> [!NOTE] Full for now\n> This campaign can't take more until the beneficiary withdraws." {
638 t.Errorf("a full escrow, its cap the smallest gift: %q", b)
639 }
640 escrowCap, minDonation, c.state.raised = saved, savedMin, 0
641 c.params.DormancyPeriod = 30 * secondsPerDay // the campaign's own, not the live setting's 90
642 c.createdAt = now() - c.params.DormancyPeriod
643 if b := banner(); b != "> [!NOTE] Inactive\n> No gift and no update for 30 days: anyone can close it now." {
644 t.Errorf("dormant: %q", b)
645 }
646 c.createdAt = now()
647 s := c.state
648 s.status, s.closedAt, s.closeReason, s.raised = statusClosed, now(), reasonOrganizer, 7_000_000
649 if b := banner(); !strings.HasPrefix(b, "> [!NOTE] Closed to new donations\n> Closed by its organizer on ") || !strings.Contains(b, "The beneficiary can withdraw the 7 GNOT held until ") || !strings.Contains(b, ", 60 days left; after that, donors can reclaim their share.") {
650 t.Errorf("closed, before the exit window: %q", b)
651 }
652 s.closedAt, s.everWithdrawn = now()-c.params.ExitDelay, true
653 if b := banner(); !strings.Contains(b, "The beneficiary didn't collect in time: a donor who hasn't reclaimed their share yet can, at any time.") {
654 t.Errorf("closed, the exit window open: %q", b)
655 }
656 s.closeReason = reasonDormant
657 if b := banner(); !strings.Contains(b, "after 30 days with no gift and no update.") {
658 t.Errorf("closed as dormant: %q", b)
659 }
660 s.closeReason, s.raised = reasonDeadline, 0
661 if b := banner(); !strings.HasSuffix(b, "It ended at its deadline, on "+when(s.closedAt)+".") {
662 t.Errorf("ended at its deadline, nothing held: %q", b)
663 }
664 s.status = statusSettled
665 if b := banner(); b != "> [!SUCCESS] Completed\n> Closed to new donations on "+when(s.closedAt)+", then settled: the organizer's deposit went back to them." {
666 t.Errorf("settled: %q", b)
667 }
668}
669
670// TestRenderMode: the mode in words says what it means now, with the refund link
671// while a refund window is open to some donor (UI spec §4.3).
672func TestRenderMode(t *testing.T) {
673 c := testCampaign(9974, modeEscrow, 0)
674 id, unstore := storeCampaign(c)
675 defer unstore()
676 mode := func() string {
677 page := Render("c/" + id)
678 i := strings.Index(page, "\n**Escrow**: ")
679 if i < 0 {
680 i = strings.Index(page, "\n**Direct**: ")
681 }
682 if i < 0 {
683 t.Fatalf("no mode line:\n%s", page)
684 }
685 return page[i+1 : i+1+strings.Index(page[i+1:], "\n")]
686 }
687 refund := " [Refund](/r/g12yg9nh4ncma44emgm8msxe8aavzywt0p95tanv/bolo/v0$help&func=Refund&id=" + id + ")"
688 if m := mode(); m != "**Escrow**: the gifts stay in escrow until the beneficiary withdraws them. Until then, a donor can take their gift back." {
689 t.Errorf("escrow, nothing given: %q", m)
690 }
691 c.state.raised = 5_000_000
692 testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", 5_000_000))) // the bank holds what the books say
693 if m := mode(); !strings.HasSuffix(m, "a donor can take their gift back."+refund) {
694 t.Errorf("escrow, gifts held, nothing withdrawn: %q", m)
695 }
696 c.state.status, c.state.closedAt = statusClosed, now() // closed, the beneficiary's window running
697 if m := mode(); !strings.HasSuffix(m, "a donor can take their gift back."+refund) {
698 t.Errorf("escrow, closed, nothing withdrawn, the window running: %q", m)
699 }
700 c.state.closedAt = now() - c.params.ExitDelay // never withdrawn, the window passed
701 if m := mode(); m != "**Escrow**: "+"the beneficiary didn't collect in time, so a donor who hasn't reclaimed their share yet can, at any time."+refund {
702 t.Errorf("escrow, the beneficiary's window passed with nothing withdrawn: %q", m)
703 }
704 c.state.status = statusSettled
705 if m := mode(); m != "**Escrow**: "+"the beneficiary didn't collect in time, so a donor who hasn't reclaimed their share yet can, at any time."+refund {
706 t.Errorf("escrow, settled with nothing withdrawn and coins held: %q", m)
707 }
708 c.state.refunded = 5_000_000 // every gift back: nothing held
709 if m := mode(); m != "**Escrow**: no gift is held any more." {
710 t.Errorf("escrow, settled, nothing held: %q", m)
711 }
712 c.state.status, c.state.closedAt = statusClosed, now()
713 if m := mode(); m != "**Escrow**: no gift is held any more." {
714 t.Errorf("escrow, closed, nothing held: %q", m)
715 }
716 c.state.status, c.state.closedAt, c.state.refunded = statusOpen, 0, 0
717 c.state.withdrawn, c.state.everWithdrawn = 2_000_000, true
718 if m := mode(); m != "**Escrow**: the beneficiary has withdrawn funds, so gifts can't be refunded now." {
719 t.Errorf("escrow, after a withdrawal: %q", m)
720 }
721 c.state.status, c.state.closedAt = statusClosed, now()
722 if m := mode(); m != "**Escrow**: the beneficiary has withdrawn funds, so gifts can't be refunded now." {
723 t.Errorf("escrow, closed after a withdrawal, the window running: %q", m)
724 }
725 c.state.closedAt = now() - c.params.ExitDelay
726 if m := mode(); m != "**Escrow**: "+"the beneficiary didn't collect in time, so a donor who hasn't reclaimed their share yet can, at any time."+refund {
727 t.Errorf("escrow, the exit window open: %q", m)
728 }
729 c.mode = modeDirect
730 if m := mode(); m != "**Direct**: each gift goes straight to the beneficiary, in the same transaction. Gifts can't be refunded." {
731 t.Errorf("direct: %q", m)
732 }
733}
734
735// TestProgressBar: the bar fills min(net, goal) of its width, its text the uncapped
736// percentage; with no goal there is none; a share never overflows (UI spec §5).
737func TestProgressBar(t *testing.T) {
738 fill := func(bar string) string {
739 i := strings.LastIndex(bar, "%3Crect%20width='")
740 rest := bar[i+len("%3Crect%20width='"):]
741 return rest[:strings.Index(rest, "'")]
742 }
743 for _, tc := range []struct {
744 net, goal int64
745 width int
746 alt, filled string
747 }{
748 {0, 400, barWide, "0%", "0"},
749 {90_000_000, 400_000_000, barWide, "22%", "225"},
750 {90_000_000, 400_000_000, barCard, "22%", "90"},
751 {496_000_000, 400_000_000, barWide, "124%", "1000"},
752 {1, 3, barWide, "33%", "333"},
753 } {
754 bar := progressBar(tc.net, tc.goal, tc.width)
755 if !strings.HasPrefix(bar, " || fill(bar) != tc.filled || !strings.Contains(bar, "width='"+strconv.Itoa(tc.width)+"'%20height='10'") {
756 t.Errorf("progressBar(%d, %d, %d) = %s", tc.net, tc.goal, tc.width, bar)
757 }
758 }
759 if bar := progressBar(5, 0, barWide); bar != "" {
760 t.Errorf("a bar with no goal: %s", bar)
761 }
762 if got := share(math.MaxInt64, 1, 1000); got <= 0 {
763 t.Errorf("share overflowed: %d", got)
764 }
765 if got := share(-5, 10, 100); got != 0 {
766 t.Errorf("share of a negative part: %d", got)
767 }
768}
769
770// TestRenderCards: campaigns are cards two to a row, a lone last one in a row of its
771// own with an empty column; a card says what a donor weighs (UI spec §4.13).
772func TestRenderCards(t *testing.T) {
773 var b strings.Builder
774 v := CampaignView{ID: "1", Title: "Well", Category: "water", Mode: modeEscrow, Status: statusOpen, NetRaised: 3_000_000, Goal: 10_000_000, DonorCount: 1, Bond: 5_000_000, Notice: "Under review"}
775 writeCards(&b, []CampaignView{v, v, v})
776 out := b.String()
777 if strings.Count(out, "<gno-columns>\n") != 2 || strings.Count(out, "</gno-columns>\n\n") != 2 || strings.Count(out, "\n<gno-columns-sep />\n") != 2 ||
778 strings.Contains(out, "<gno-columns-sep>") || !strings.HasSuffix(out, "<gno-columns-sep />\n</gno-columns>\n\n") {
779 t.Errorf("not two to a row:\n%s", out)
780 }
781 if !strings.Contains(out, "**3 GNOT** of 10 GNOT · 1 donor \nEscrow · water · no deadline · bond 5 GNOT · **moderator's notice**\n") {
782 t.Errorf("a card's lines:\n%s", out)
783 }
784 b.Reset()
785 v.Goal, v.Deadline, v.Bond, v.Notice, v.Status = 0, now()+3*secondsPerDay+60, 0, "", statusOpen
786 writeCards(&b, []CampaignView{v, v})
787 if out = b.String(); strings.Contains(out, "data:image") || !strings.Contains(out, "**3 GNOT** raised · 1 donor \nEscrow · water · 3 days left\n") || strings.HasSuffix(out, "<gno-columns-sep />\n</gno-columns>\n\n") {
788 t.Errorf("two cards with no goal:\n%s", out)
789 }
790 for status, word := range map[string]string{statusClosed: "closed to new donations", statusSettled: "completed"} {
791 v.Status = status
792 if got := timeWord(v); got != word {
793 t.Errorf("timeWord(%s) = %q", status, got)
794 }
795 }
796 if got := left(now() + secondsPerDay - 1); got != "less than a day left" {
797 t.Errorf("left, under a day: %q", got)
798 }
799}
800
801// TestRenderLedgerDirect: a direct campaign's money out says nothing goes out, and
802// its page doesn't link there (API §5).
803func TestRenderLedgerDirect(t *testing.T) {
804 c := testCampaign(9975, modeDirect, 0)
805 id, unstore := storeCampaign(c)
806 defer unstore()
807 if page := Render("c/" + id + "/ledger"); strings.Contains(page, "## Withdrawals") || !strings.Contains(page, "**Direct**: each gift went straight to the beneficiary, `"+c.beneficiary.String()+"`") {
808 t.Errorf("a direct campaign's money out:\n%s", page)
809 }
810 if page := Render("c/" + id); strings.Contains(page, "/ledger)") || strings.Contains(page, "| Held in escrow |") {
811 t.Errorf("a direct campaign's page links to money out, or shows an escrow:\n%s", page)
812 }
813}
814
815// TestRenderStart: the home page links to CreateCampaign's form, the deposit attached,
816// only while this version takes new campaigns; paused or sunset, it says why, and a
817// sunset version points to the registry, where new campaigns start (UI spec §4.13).
818func TestRenderStart(t *testing.T) {
819 defer func() { paused, sunset = false, false }()
820 start := "[Start a campaign](/r/g12yg9nh4ncma44emgm8msxe8aavzywt0p95tanv/bolo/v0$help&func=CreateCampaign&.send=" + strconv.FormatInt(params.CreationDeposit, 10) + "ugnot) — a " + gnot(params.CreationDeposit) + " deposit, returned when it completes · [Find an address](/r/g12yg9nh4ncma44emgm8msxe8aavzywt0p95tanv/bolo/v0:u)\n"
821 if home := Render(""); !strings.Contains(home, start) {
822 t.Errorf("no link to start a campaign:\n%s", home)
823 }
824 paused = true
825 if home := Render(""); strings.Contains(home, "func=CreateCampaign") || !strings.Contains(home, "Bolo is paused: new campaigns and gifts wait until it resumes · [Find an address]") {
826 t.Errorf("paused:\n%s", home)
827 }
828 paused, sunset = false, true
829 if home := Render(""); strings.Contains(home, "func=CreateCampaign") || !strings.Contains(home, "This version of Bolo takes no new campaigns: [see where they start](/r/g12yg9nh4ncma44emgm8msxe8aavzywt0p95tanv/bolo) · [Find an address]") {
830 t.Errorf("sunset:\n%s", home)
831 }
832 paused = true // and sunset, which is for good
833 if home, params := Render(""), Render("params"); !strings.Contains(home, "This version of Bolo takes no new campaigns") || !strings.Contains(params, "- New campaigns: not on this version\n") {
834 t.Errorf("paused and sunset:\n%s\n%s", home, params)
835 }
836}
837
838// TestParseGNOT: an amount in GNOT parses only in its one grammar (API §5).
839func TestParseGNOT(t *testing.T) {
840 for s, want := range map[string]int64{"1": 1_000_000, "7.5": 7_500_000, "7%2C5": 7_500_000, "0.000001": 1, "0.5": 500_000, "999999999999": 999_999_999_999_000_000, "10.25": 10_250_000,
841 "+7.5+": 7_500_000, "++7.5": 7_500_000, "1%2C00": 1_000_000, "1%2C0000": 1_000_000, "1.000": 1_000_000} {
842 if got, ok := parseGNOT(s); !ok || got != want {
843 t.Errorf("parseGNOT(%q) = %d, %t; want %d", s, got, ok, want)
844 }
845 }
846 for _, s := range []string{"", ".5", "1.", "01", "00.5", "-1", "1e3", "1.1234567", "1,5", "7%2c5", "1 ", " 1", "1..5", "7.5.1", "1234567890123", "%2C5", "1%2C",
847 "1%2C000", "12%2C500", "+", "+-1", "7+5"} {
848 if got, ok := parseGNOT(s); ok {
849 t.Errorf("parseGNOT(%q) accepted: %d", s, got)
850 }
851 }
852}
853
854// TestRenderGive: another amount's page asks the amount in gnoweb's form and answers
855// one Donate would take with its one-click link; any other gets the form again with
856// what the campaign can take, echoing nothing; a campaign that takes no gift says so
857// (API §5).
858func TestRenderGive(t *testing.T) {
859 c := testCampaign(9976, modeEscrow, 0)
860 id, unstore := storeCampaign(c)
861 defer unstore()
862 saved := escrowCap
863 defer func() { escrowCap, paused = saved, false }()
864 escrowCap = 30_000_000
865 c.state.raised = 12_000_000 // 18 GNOT of room
866 page := func(q string) string { return Render("c/" + id + "/give" + q) }
867 form := "\n<gno-form path=\"c/" + id + "/give\">\n <gno-input name=\"amount\" placeholder=\"7.5\" />\n</gno-form>\n"
868 if p := page(""); !strings.Contains(p, form) || !strings.Contains(p, "Any amount from 1 GNOT to 18 GNOT, in GNOT.") || strings.Contains(p, "isn't") {
869 t.Errorf("the page for another amount:\n%s", p)
870 }
871 link := "**[Give 7.5 GNOT](/r/g12yg9nh4ncma44emgm8msxe8aavzywt0p95tanv/bolo/v0$help&func=Donate&.send=7500000ugnot&anonymous=false&id=" + id + "&message=&tip=0)**"
872 for _, q := range []string{"?amount=7.5", "?amount=7%2C5"} {
873 if p := page(q); !strings.Contains(p, link) || strings.Contains(p, "<gno-form") {
874 t.Errorf("%s: no link for 7.5 GNOT, or the form again:\n%s", q, p)
875 }
876 }
877 if p := page("?amount=18"); !strings.Contains(p, "&.send=18000000ugnot&") {
878 t.Errorf("the whole room refused:\n%s", p)
879 }
880 for _, bad := range []string{"0.5", "18.000001", "50", "zz9", "7.5.1", "%3Cb%3Eecho", "1234567890123"} {
881 p := page("?amount=" + bad)
882 if !strings.Contains(p, "That isn't an amount this campaign can take.") || !strings.Contains(p, form) || strings.Contains(p, "func=Donate") {
883 t.Errorf("?amount=%s: a link, or no form and line saying why:\n%s", bad, p)
884 }
885 if strings.Contains(p, bad) {
886 t.Errorf("?amount=%s: the page echoes it:\n%s", bad, p)
887 }
888 }
889 if p := page("?amount=1"); !strings.Contains(p, "&.send=1000000ugnot&") {
890 t.Errorf("exactly the smallest gift refused:\n%s", p)
891 }
892 escrowCap = 0
893 if p := page(""); !strings.Contains(p, "Any amount from 1 GNOT, in GNOT.") {
894 t.Errorf("no cap, yet a most said:\n%s", p)
895 }
896 // No link while Bolo is paused, the campaign is hidden or closed, or the cap leaves
897 // less room than the smallest gift: the page says so, and links back.
898 none := func(why string) {
899 if p := page("?amount=1"); strings.Contains(p, "func=Donate") || strings.Contains(p, "<gno-form") || !strings.Contains(p, "Bolo offers no gift link for this campaign now: its page says why.") {
900 t.Errorf("%s: a link, or the form:\n%s", why, p)
901 }
902 }
903 paused = true
904 none("paused")
905 paused = false
906 c.state.listed = false
907 none("hidden from listings")
908 c.state.listed = true
909 escrowCap = 12_500_000 // half a GNOT of room
910 none("no room")
911 escrowCap = 0
912 c.state.status = statusClosed
913 none("closed")
914}
915
916// TestRenderMoney: where the money is comes from the books — the escrow's coins sent
917// outside Bolo said apart — and in direct mode all went straight through, with no money
918// out on its receipt; the bond reads as at stake, its hold over, or slashed.
919func TestRenderMoney(t *testing.T) {
920 c := testCampaign(9977, modeEscrow, 0)
921 id, unstore := storeCampaign(c)
922 defer unstore()
923 donor := testutils.TestAddress("moneydonor")
924 giveTo(c, 1, donor, 40_000_000)
925 defer byDonor.Remove(donor.String() + "/" + c.id.Binary())
926 c.state.withdrawn, c.state.refunded, c.state.everWithdrawn = 20_000_000, 2_000_000, true
927 testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", 27_000_000))) // 18 held, and 9 sent outside Bolo
928 page := Render("c/" + id)
929 for _, want := range []string{"| Given: 1 gift from 1 donor | 40 |\n", "| Refunded to donors | 2 |\n", "| **Raised**, net of refunds | **38** |\n",
930 "| Withdrawn by the beneficiary | 20 |\n", "| Held in escrow | 18 |\n", "its bank balance is the 18 GNOT held, plus 9 GNOT sent there outside Bolo"} {
931 if !strings.Contains(page, want) {
932 t.Errorf("missing %q:\n%s", want, page)
933 }
934 }
935 c.state.bond = 5_000_000
936 if p := Render("c/" + id); !strings.Contains(p, "; a 5 GNOT bond the organizer staked, lost if the campaign is delisted for fraud — not a guarantee\n") {
937 t.Errorf("a bond at stake:\n%s", p)
938 }
939 c.state.status, c.state.closedAt, c.state.closeReason = statusClosed, now()-c.params.BondHold, reasonOrganizer
940 if p := Render("c/" + id); !strings.Contains(p, "; a 5 GNOT bond the organizer staked, whose hold ended "+when(now())+": no longer at stake, it goes back to the organizer\n") {
941 t.Errorf("a bond whose hold ended:\n%s", p)
942 }
943 c.state.bondSlashed = true
944 if p := Render("c/" + id); !strings.Contains(p, "; a 5 GNOT bond, slashed\n") {
945 t.Errorf("a slashed bond:\n%s", p)
946 }
947
948 d := testCampaign(9978, modeDirect, 0)
949 did, undirect := storeCampaign(d)
950 defer undirect()
951 giveTo(d, 1, donor, 15_000_000)
952 defer byDonor.Remove(donor.String() + "/" + d.id.Binary())
953 if p := Render("c/" + did); !strings.Contains(p, "| Given: 1 gift from 1 donor | 15 |\n| Sent straight to the beneficiary | 15 |\n\n") || strings.Contains(p, "Held in escrow") {
954 t.Errorf("a direct campaign's money:\n%s", p)
955 }
956 if r := Render("c/" + did + "/r/1"); strings.Contains(r, "/ledger)") || !strings.Contains(r, "Every gift went straight to the beneficiary, `"+d.beneficiary.String()+"`, in the same transaction.") {
957 t.Errorf("a direct receipt:\n%s", r)
958 }
959}