patron_test.gno
12.84 Kb · 387 lines
1package patron
2
3import (
4 "strconv"
5 "strings"
6 "testing"
7
8 "chain"
9 "chain/banker"
10 "chain/runtime"
11
12 pt "gno.land/p/moul/x/social/patron/v0"
13 "gno.land/p/nt/testutils/v0"
14 "gno.land/p/nt/uassert/v0"
15 "gno.land/p/nt/urequire/v0"
16)
17
18const (
19 price = int64(1000000) // 1 GNOT per period
20 period = int64(100) // blocks
21)
22
23var (
24 alice = testutils.TestAddress("alice") // the creator, throughout
25 bob = testutils.TestAddress("bob")
26 carol = testutils.TestAddress("carol")
27
28 realmAddr = chain.PackageAddress(realmPath)
29)
30
31// reset clears realm state. Realm globals live for the whole test binary, so
32// everything a later test or the Render tests read starts here.
33func reset() { plans = pt.NewRegistry() }
34
35// pay stages a payment the way the chain does: the coins land at the realm's
36// address before a line of its code runs, and OriginSend is what the crossing
37// function then reads.
38func pay(amount int64) {
39 testing.IssueCoins(realmAddr, chain.Coins{{denom, amount}})
40 testing.SetOriginSend(chain.Coins{{denom, amount}})
41}
42
43// open creates a plan as alice. SetRealm is call-frame scoped, so the account
44// is switched here and then crossed from here, never from a helper that does
45// not itself cross.
46func open(cur realm, t *testing.T) int64 {
47 t.Helper()
48 testing.SetRealm(testing.NewUserRealm(alice))
49 return Open(cross(cur), "monthly support", "a plan with a | pipe in it", price, period)
50}
51
52func TestOpenStoresWhatItWasGiven(cur realm, t *testing.T) {
53 reset()
54 id := open(cur, t)
55
56 creator, title, desc, p, blocks, isOpen := Get(id)
57 uassert.Equal(t, alice.String(), creator.String())
58 uassert.Equal(t, "monthly support", title)
59 uassert.Equal(t, "a plan with a | pipe in it", desc)
60 uassert.Equal(t, price, p)
61 uassert.Equal(t, period, blocks)
62 uassert.True(t, isOpen)
63 uassert.Equal(t, 1, Count())
64 uassert.Equal(t, 0, SupporterCount(id))
65}
66
67func TestOpenRefusesAFreePlanAndASillyPeriod(cur realm, t *testing.T) {
68 reset()
69
70 testing.SetRealm(testing.NewUserRealm(alice))
71 uassert.AbortsContains(t, cur, "at least one ugnot per period", func() {
72 Open(cross(cur), "free", "", 0, period)
73 })
74 testing.SetRealm(testing.NewUserRealm(alice))
75 uassert.AbortsContains(t, cur, "period out of range", func() {
76 Open(cross(cur), "instant", "", price, 0)
77 })
78 testing.SetRealm(testing.NewUserRealm(alice))
79 uassert.AbortsContains(t, cur, "period out of range", func() {
80 Open(cross(cur), "forever", "", price, pt.MaxPeriodBlocks+1)
81 })
82 testing.SetRealm(testing.NewUserRealm(alice))
83 uassert.AbortsContains(t, cur, "title is empty", func() {
84 Open(cross(cur), " ", "", price, period)
85 })
86 uassert.Equal(t, 0, Count())
87}
88
89// One period, paid exactly, credited to the creator at payment time and
90// withdrawable as real coins in this same test, because the bank does not
91// carry over between test functions.
92func TestOnePeriodIsBoughtAndTheCreatorIsPaid(cur realm, t *testing.T) {
93 reset()
94 id := open(cur, t)
95 start := runtime.ChainHeight()
96
97 pay(price)
98 testing.SetRealm(testing.NewUserRealm(bob))
99 through := Subscribe(cross(cur), id)
100
101 uassert.Equal(t, start+period, through)
102 uassert.Equal(t, start+period, PaidThrough(id, bob))
103 uassert.True(t, IsActive(id, bob))
104 uassert.Equal(t, 1, SupporterCount(id))
105 uassert.Equal(t, price, EarnedBy(alice))
106 uassert.Equal(t, price, CreditOf(alice))
107 uassert.Equal(t, int64(0), CreditOf(bob), "an exact payment leaves no change")
108 uassert.Equal(t, price, TotalOwed())
109
110 ro := banker.NewReadonlyBanker()
111 before := ro.GetCoin(alice, denom)
112
113 testing.SetRealm(testing.NewUserRealm(alice))
114 got := Withdraw(cross(cur))
115
116 uassert.Equal(t, price, got)
117 uassert.Equal(t, price, ro.GetCoin(alice, denom)-before, "the creator was really paid")
118 uassert.Equal(t, int64(0), CreditOf(alice))
119 uassert.Equal(t, price, EarnedBy(alice), "earnings outlive the withdrawal")
120 uassert.Equal(t, int64(0), TotalOwed())
121}
122
123func TestThreePeriodsAtOnce(cur realm, t *testing.T) {
124 reset()
125 id := open(cur, t)
126 start := runtime.ChainHeight()
127
128 pay(3 * price)
129 testing.SetRealm(testing.NewUserRealm(bob))
130 through := Subscribe(cross(cur), id)
131
132 uassert.Equal(t, start+3*period, through)
133 uassert.Equal(t, 3*price, CreditOf(alice))
134 uassert.Equal(t, int64(0), CreditOf(bob))
135}
136
137// Renewing before the lapse extends from paidThrough, so the time already
138// paid for is still there afterwards.
139func TestRenewingEarlyExtendsRatherThanRestarts(cur realm, t *testing.T) {
140 reset()
141 id := open(cur, t)
142
143 pay(price)
144 testing.SetRealm(testing.NewUserRealm(bob))
145 first := Subscribe(cross(cur), id)
146
147 // Ten blocks in, with ninety still paid for.
148 testing.SkipHeights(10)
149 pay(price)
150 testing.SetRealm(testing.NewUserRealm(bob))
151 second := Subscribe(cross(cur), id)
152
153 uassert.Equal(t, first+period, second, "the second period starts where the first ended")
154 uassert.True(t, second > runtime.ChainHeight()+period,
155 "more than one period is left, which is the point")
156 uassert.Equal(t, 1, SupporterCount(id), "renewing is not a second supporter")
157}
158
159// Renewing after a lapse starts from now: the gap was never paid for.
160func TestRenewingAfterALapseStartsFromNow(cur realm, t *testing.T) {
161 reset()
162 id := open(cur, t)
163
164 pay(price)
165 testing.SetRealm(testing.NewUserRealm(bob))
166 Subscribe(cross(cur), id)
167
168 testing.SkipHeights(500)
169 uassert.False(t, IsActive(id, bob), "the first period lapsed long ago")
170
171 pay(price)
172 testing.SetRealm(testing.NewUserRealm(bob))
173 second := Subscribe(cross(cur), id)
174
175 uassert.Equal(t, runtime.ChainHeight()+period, second)
176 uassert.True(t, IsActive(id, bob))
177}
178
179// IsActive flips at the block the period ends on, and not a block either side.
180func TestIsActiveFlipsAtTheRightHeight(cur realm, t *testing.T) {
181 reset()
182 id := open(cur, t)
183 start := runtime.ChainHeight()
184
185 pay(price)
186 testing.SetRealm(testing.NewUserRealm(bob))
187 Subscribe(cross(cur), id)
188
189 testing.SetHeight(start + period - 1)
190 uassert.True(t, IsActive(id, bob), "the last block of the period")
191
192 testing.SetHeight(start + period)
193 uassert.False(t, IsActive(id, bob), "the first block after it")
194
195 uassert.False(t, IsActive(id, carol), "an address that never paid is never active")
196 uassert.Equal(t, int64(0), PaidThrough(id, carol))
197}
198
199func TestAPaymentShortOfOnePeriodIsRefused(cur realm, t *testing.T) {
200 reset()
201 id := open(cur, t)
202
203 pay(price - 1)
204 testing.SetRealm(testing.NewUserRealm(bob))
205 uassert.AbortsContains(t, cur, "this call costs at least", func() {
206 Subscribe(cross(cur), id)
207 })
208
209 uassert.Equal(t, int64(0), PaidThrough(id, bob))
210 uassert.Equal(t, int64(0), EarnedBy(alice))
211 uassert.Equal(t, 0, SupporterCount(id))
212}
213
214// The remainder under one period is the supporter's, not the realm's, and it
215// comes back out as real coins.
216func TestChangeIsCreditedBackAndWithdrawable(cur realm, t *testing.T) {
217 reset()
218 id := open(cur, t)
219
220 pay(2*price + 250000)
221 testing.SetRealm(testing.NewUserRealm(bob))
222 Subscribe(cross(cur), id)
223
224 uassert.Equal(t, int64(250000), CreditOf(bob), "the change is the supporter's")
225 uassert.Equal(t, 2*price, CreditOf(alice))
226 uassert.Equal(t, 2*price+250000, TotalOwed(), "every ugnot in is owed to somebody")
227
228 ro := banker.NewReadonlyBanker()
229 before := ro.GetCoin(bob, denom)
230
231 testing.SetRealm(testing.NewUserRealm(bob))
232 got := Withdraw(cross(cur))
233
234 uassert.Equal(t, int64(250000), got)
235 uassert.Equal(t, int64(250000), ro.GetCoin(bob, denom)-before)
236 uassert.Equal(t, int64(0), CreditOf(bob))
237
238 testing.SetRealm(testing.NewUserRealm(bob))
239 uassert.AbortsContains(t, cur, "nothing to withdraw", func() { Withdraw(cross(cur)) })
240}
241
242func TestWithdrawRefusesSomebodyOwedNothing(cur realm, t *testing.T) {
243 reset()
244 testing.SetRealm(testing.NewUserRealm(carol))
245 uassert.AbortsContains(t, cur, "nothing to withdraw", func() { Withdraw(cross(cur)) })
246}
247
248// A realm that the user called holds the coins itself and could subscribe
249// over and over against one payment. The guard runs before the envelope is
250// read, so it cannot.
251func TestOnlyAUserCanSubscribe(cur realm, t *testing.T) {
252 reset()
253 id := open(cur, t)
254
255 pay(price)
256 testing.SetRealm(testing.NewCodeRealm("gno.land/r/g1relay/forwarder"))
257 uassert.AbortsContains(t, cur, "must be called directly by a user", func() {
258 Subscribe(cross(cur), id)
259 })
260 uassert.Equal(t, int64(0), EarnedBy(alice))
261}
262
263func TestCloseAndReopenAreTheCreatorsAlone(cur realm, t *testing.T) {
264 reset()
265 id := open(cur, t)
266
267 pay(price)
268 testing.SetRealm(testing.NewUserRealm(bob))
269 Subscribe(cross(cur), id)
270
271 testing.SetRealm(testing.NewUserRealm(bob))
272 uassert.AbortsContains(t, cur, "only the plan's creator", func() { Close(cross(cur), id) })
273
274 testing.SetRealm(testing.NewUserRealm(alice))
275 Close(cross(cur), id)
276 _, _, _, _, _, isOpen := Get(id)
277 uassert.False(t, isOpen)
278 uassert.True(t, IsActive(id, bob), "closing does not take back a paid period")
279
280 pay(price)
281 testing.SetRealm(testing.NewUserRealm(carol))
282 uassert.AbortsContains(t, cur, "closed to new subscriptions", func() {
283 Subscribe(cross(cur), id)
284 })
285
286 testing.SetRealm(testing.NewUserRealm(alice))
287 uassert.AbortsContains(t, cur, "already closed", func() { Close(cross(cur), id) })
288
289 testing.SetRealm(testing.NewUserRealm(bob))
290 uassert.AbortsContains(t, cur, "only the plan's creator", func() { Reopen(cross(cur), id) })
291
292 testing.SetRealm(testing.NewUserRealm(alice))
293 Reopen(cross(cur), id)
294 testing.SetRealm(testing.NewUserRealm(alice))
295 uassert.AbortsContains(t, cur, "already open", func() { Reopen(cross(cur), id) })
296
297 pay(price)
298 testing.SetRealm(testing.NewUserRealm(carol))
299 Subscribe(cross(cur), id)
300 uassert.Equal(t, 2, SupporterCount(id))
301}
302
303func TestAPlanThatIsNotThere(cur realm, t *testing.T) {
304 reset()
305 testing.SetRealm(testing.NewUserRealm(bob))
306 uassert.AbortsContains(t, cur, "plan #4242 not found", func() {
307 Subscribe(cross(cur), 4242)
308 })
309 uassert.PanicsContains(t, cur, "plan #4242 not found", func() { Count(); IsActive(4242, bob) })
310}
311
312func TestSupportersAreDistinctAndInFirstPaymentOrder(cur realm, t *testing.T) {
313 reset()
314 id := open(cur, t)
315
316 pay(price)
317 testing.SetRealm(testing.NewUserRealm(carol))
318 Subscribe(cross(cur), id)
319 pay(price)
320 testing.SetRealm(testing.NewUserRealm(bob))
321 Subscribe(cross(cur), id)
322 pay(price)
323 testing.SetRealm(testing.NewUserRealm(carol))
324 Subscribe(cross(cur), id)
325
326 got := Supporters(id)
327 urequire.Equal(t, 2, len(got))
328 uassert.Equal(t, carol.String(), got[0].String())
329 uassert.Equal(t, bob.String(), got[1].String())
330}
331
332// TestRender uses uassert rather than an Example because every view contains
333// consecutive blank lines, which gno collapses inside an // Output: block.
334func TestRender(cur realm, t *testing.T) {
335 reset()
336
337 empty := Render("")
338 uassert.True(t, strings.Contains(empty, "No plans yet."), empty)
339 uassert.True(t, strings.Contains(empty, "no cron on chain"), "the index says it: "+empty)
340
341 testing.SetRealm(testing.NewUserRealm(alice))
342 id := Open(cross(cur), "a | pipe and [a link](x)", "about **everything**", price, period)
343
344 pay(price)
345 testing.SetRealm(testing.NewUserRealm(bob))
346 Subscribe(cross(cur), id)
347
348 index := Render("")
349 uassert.False(t, strings.Contains(index, "| a | pipe"),
350 "a raw pipe would open a column: "+index)
351 uassert.True(t, strings.Contains(index, "a \\| pipe and \\[a link\\]\\(x\\)"),
352 "the title is escaped once in the listing, pipe included: "+index)
353 uassert.True(t, strings.Contains(index, "["+strconv.FormatInt(id, 10)+"](/r/moul/x/social/patron/v0:plan/"),
354 "the link text is the plan number, which is ours and not the caller's: "+index)
355 uassert.True(t, strings.Contains(index, "func=Open"), "the index offers the transaction")
356 uassert.True(t, strings.Contains(index, "1 of 1"), "one active of one supporter: "+index)
357
358 sid := strconv.FormatInt(id, 10)
359 page := Render("plan/" + sid)
360 uassert.True(t, strings.Contains(page, "# Plan #"+sid))
361 // A heading is prose, so ui.Inline escapes the link syntax and leaves the
362 // pipe alone: a pipe only means something inside a table.
363 uassert.True(t, strings.Contains(page, "# Plan #"+sid+": a | pipe and \\[a link\\]\\(x\\)"),
364 "the title is escaped in the heading too: "+page)
365 uassert.True(t, strings.Contains(page, "about \\*\\*everything\\*\\*"),
366 "the description is escaped: "+page)
367 uassert.True(t, strings.Contains(page, "func=Subscribe"))
368 uassert.True(t, strings.Contains(page, ".send="+strconv.FormatInt(price, 10)+"ugnot"),
369 "the support link carries the price: "+page)
370 uassert.True(t, strings.Contains(page, "func=Close"))
371
372 testing.SetRealm(testing.NewUserRealm(alice))
373 Close(cross(cur), id)
374 closed := Render("plan/" + sid)
375 uassert.True(t, strings.Contains(closed, "takes no new subscriptions"), closed)
376 uassert.False(t, strings.Contains(closed, "func=Subscribe"),
377 "a closed plan offers no payment: "+closed)
378
379 uassert.Equal(t, "# Patron\nNo plan #4242", Render("plan/4242"))
380 uassert.Equal(t, "# Patron\nNot a plan id: nope", Render("plan/nope"))
381 uassert.True(t, strings.HasPrefix(Render("elsewhere"), "# Not found"))
382
383 // A page past the end is an empty listing, not a panic.
384 uassert.True(t, strings.Contains(Render("?page=99"), "No plans yet."))
385 uassert.True(t, strings.Contains(Render("?page=nonsense"), "a \\| pipe"),
386 "an unreadable page number falls back to the first")
387}