rastrillo / abono Public

Clone
git clone https://amadan.net/rastrillo/abono

Plain git — no account needed to clone.

Download

Download this file

1package abono_test
2
3import (
4 "errors"
5 "html/template"
6 "io/fs"
7 "os"
8 "path"
9 "regexp"
10 "slices"
11 "strings"
12 "testing"
13 "time"
14
15 "amadan.net/rastrillo/rastrillo"
16 _ "amadan.net/rastrillo/rastrillo/locales/en"
17 "amadan.net/rastrillo/rastrillo/ui"
18
19 "amadan.net/rastrillo/abono"
20)
21
22// testCatalog is made-up English for these tests, one entry per message
23// key the partials use. It never ships: the approved English is the
24// subject of the copy review and lands in locales/en.toml, and these
25// strings only let a test see which key a partial reached for.
26var testCatalog = rastrillo.Catalog{
27 "abono.status.active": "T-ACTIVE",
28 "abono.status.renews": "T-RENEWS {date}",
29 "abono.status.cancelling": "T-CANCELLING {date}",
30 "abono.status.failing": "T-FAILING {date}",
31 "abono.status.ended": "T-ENDED",
32 "abono.status.comp": "T-COMP",
33 "abono.status.comp_until": "T-COMPUNTIL {date}",
34 "abono.price.per_month": "T-PERMONTH {amount}",
35 "abono.price.per_year": "T-PERYEAR {amount}",
36 "abono.price.monthly": "T-MONTHLY",
37 "abono.price.yearly": "T-YEARLY",
38 "abono.price.switch_label": "T-SWITCH",
39 "abono.plan.interval_unavailable": "T-NOINTERVAL",
40 "abono.plan.code_label": "T-CODELABEL",
41 "abono.plan.code_hint": "T-CODEHINT",
42 "abono.paywall.title": "T-PAYWALL-TITLE",
43 "abono.paywall.body": "T-PAYWALL-BODY",
44 "abono.paywall.signin_prompt": "T-SIGNIN-PROMPT",
45 "abono.paywall.signin": "T-SIGNIN",
46 "abono.button.subscribe": "T-SUBSCRIBE",
47 "abono.button.manage": "T-MANAGE",
48 "abono.button.see_plans": "T-SEEPLANS",
49}
50
51// partialNames are the partials abono ships, each with data that renders it.
52var partialNames = []string{
53 "abono-status", "abono-plan-card", "abono-plan-picker", "abono-paywall",
54 "abono-subscribe-button", "abono-billing-button", "abono-price",
55}
56
57// testLocalizer is a localizer over the framework's own English with
58// catalog merged in below it, the way an app merges abono.Catalog() into
59// the base catalog it hands rastrillo.NewLocales.
60func testLocalizer(t *testing.T, catalog rastrillo.Catalog) *ui.Localizer {
61 t.Helper()
62 base := rastrillo.BaseCatalog()
63 for k, v := range catalog {
64 base[k] = v
65 }
66 locs, err := rastrillo.NewLocales([]string{"en"}, "en", base, nil)
67 if err != nil {
68 t.Fatalf("NewLocales: %v", err)
69 }
70 return ui.NewLocalizer("en", "en", func(key string, args ...any) string {
71 return locs.Tf("en", key, args...)
72 })
73}
74
75// partials parses ui's partials and abono's, as an app does.
76func partials(t *testing.T) *template.Template {
77 t.Helper()
78 tmpl, err := template.New("").Funcs(ui.Funcs()).ParseFS(ui.Templates(), "*.html")
79 if err != nil {
80 t.Fatalf("parse ui: %v", err)
81 }
82 if tmpl, err = tmpl.ParseFS(abono.Templates(), "*.html"); err != nil {
83 t.Fatalf("parse abono: %v", err)
84 }
85 return tmpl
86}
87
88func render(t *testing.T, name string, data any) string {
89 t.Helper()
90 var b strings.Builder
91 if err := partials(t).ExecuteTemplate(&b, name, data); err != nil {
92 t.Fatalf("ExecuteTemplate(%q): %v", name, err)
93 }
94 return b.String()
95}
96
97var (
98 soon = time.Date(2026, 11, 3, 9, 30, 0, 0, time.UTC)
99 nowAt = time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)
100 yearly = abono.Price{ID: "price_y", Sold: true, Interval: "year", Amount: 5000, Currency: "EUR"}
101 monthly = abono.Price{ID: "price_m", Sold: true, Interval: "month", Amount: 500, Currency: "EUR"}
102 oldYr = abono.Price{ID: "price_old", Sold: false, Interval: "year", Amount: 4000, Currency: "EUR"}
103 // dozen is sold, monthly by Stripe's interval, but every twelve months:
104 // v0.1 cannot sell it and must never show it as €120 a month.
105 dozen = abono.Price{ID: "price_12m", Sold: true, Interval: "month", IntervalCount: 12, Amount: 12000, Currency: "EUR"}
106)
107
108func supporter() abono.Offer {
109 return abono.Offer{Plan: "supporter", Name: "Supporter", Prices: []abono.Price{monthly, yearly, oldYr}}
110}
111
112func yearlyOnly() abono.Offer {
113 return abono.Offer{Plan: "patron", Name: "Patron", Prices: []abono.Price{yearly}}
114}
115
116// sample is data that renders each partial with every optional key set.
117func sample(l *ui.Localizer) map[string]map[string]any {
118 holding := abono.Holding{Plan: "supporter", Source: abono.SourceSubscription, Status: "active", Until: &soon, Grants: true}
119 return map[string]map[string]any{
120 "abono-status": {"L": l, "Holding": holding, "Now": nowAt},
121 "abono-plan-card": {"L": l, "Offer": supporter(), "Interval": "year", "Action": "/subscribe", "Code": true},
122 "abono-plan-picker": {"L": l, "Offers": []abono.Offer{supporter(), yearlyOnly()}, "Action": "/subscribe"},
123 "abono-paywall": {"L": l, "Offers": []abono.Offer{supporter()}, "SignedIn": true, "SigninURL": "/signin", "Action": "/subscribe"},
124 "abono-subscribe-button": {"L": l, "Action": "/subscribe", "Plan": "supporter", "Interval": "year", "Label": "Join"},
125 "abono-billing-button": {"L": l, "Action": "/billing"},
126 "abono-price": {"L": l, "Price": monthly},
127 }
128}
129
130// whole renders, so a missing key or a stray placeholder shows.
131func checkClean(t *testing.T, name, got string) {
132 t.Helper()
133 for _, bad := range []string{
134 "http://", "https://", "//cdn", "<script", "<iframe", "<link ", "url(",
135 "xlink:href", "@import", "<style", " style=",
136 "<no value>", "abono.", "ZgotmplZ",
137 } {
138 if strings.Contains(got, bad) {
139 t.Errorf("partial %q renders %q:\n%s", name, bad, got)
140 }
141 }
142}
143
144func TestEveryPartialIsDefinedAndSelfContained(t *testing.T) {
145 l := testLocalizer(t, testCatalog)
146 data := sample(l)
147 tmpl := partials(t)
148 for _, name := range partialNames {
149 if tmpl.Lookup(name) == nil {
150 t.Errorf("partial %q is not defined by abono.Templates()", name)
151 continue
152 }
153 d, ok := data[name]
154 if !ok {
155 t.Fatalf("no sample data for %q", name)
156 }
157 checkClean(t, name, render(t, name, d))
158 }
159}
160
161func TestTemplatesIsRootedAtThePartials(t *testing.T) {
162 entries, err := fs.ReadDir(abono.Templates(), ".")
163 if err != nil || len(entries) == 0 {
164 t.Fatalf("ReadDir: %v, %d entries", err, len(entries))
165 }
166 for _, e := range entries {
167 if !strings.HasSuffix(e.Name(), ".html") {
168 t.Errorf("unexpected entry %q at the root of Templates()", e.Name())
169 }
170 }
171}
172
173// holdingCase is a holding that Describe reads as one status key.
174type holdingCase struct {
175 name string
176 h abono.Holding
177 key string
178 tone string
179 text string // the test catalog's text
180 hasDate bool
181}
182
183func holdingCases() []holdingCase {
184 sub := func(status string, cancel bool) abono.Holding {
185 return abono.Holding{Plan: "supporter", Source: abono.SourceSubscription, Status: status, Until: &soon, CancelAtPeriodEnd: cancel, Grants: true}
186 }
187 started := nowAt.Add(-48 * time.Hour)
188 failing := sub("past_due", false)
189 failing.PeriodStart = &started
190 active := abono.Holding{Plan: "supporter", Source: abono.SourceSubscription, Status: "active", Grants: true}
191 noStart := sub("past_due", false) // no period start: counted from the end
192 compUntil := abono.Holding{Plan: "supporter", Source: abono.SourceComp, Status: "active", Until: &soon, Grants: true}
193 return []holdingCase{
194 {"active", active, abono.StatusActive, "positive", "T-ACTIVE", false},
195 {"renews", sub("active", false), abono.StatusRenews, "positive", "T-RENEWS", true},
196 {"cancelling", sub("active", true), abono.StatusCancelling, "warning", "T-CANCELLING", true},
197 {"failing", failing, abono.StatusFailing, "warning", "T-FAILING", true},
198 {"failing, no period start", noStart, abono.StatusFailing, "warning", "T-FAILING", true},
199 {"ended", sub("canceled", false), abono.StatusEnded, "neutral", "T-ENDED", false},
200 {"comp", abono.Holding{Plan: "supporter", Source: abono.SourceComp, Status: "active", Grants: true}, abono.StatusComp, "positive", "T-COMP", false},
201 {"comp_until", compUntil, abono.StatusCompUntil, "positive", "T-COMPUNTIL", true},
202 }
203}
204
205// The date is written as the year-month-day it is, the one form that
206// reads the same in every language, until core has a date helper that
207// reads a time in the reader's locale.
208func TestStatusRendersEveryKey(t *testing.T) {
209 l := testLocalizer(t, testCatalog)
210 seen := map[string]bool{}
211 for _, c := range holdingCases() {
212 t.Run(c.name, func(t *testing.T) {
213 s := abono.Describe(c.h, nowAt)
214 if s.Key != c.key || s.Tone != c.tone {
215 t.Fatalf("Describe = %q/%q, case wants %q/%q", s.Key, s.Tone, c.key, c.tone)
216 }
217 seen[s.Key] = true
218 got := render(t, "abono-status", map[string]any{"L": l, "Holding": c.h, "Now": nowAt})
219 checkClean(t, "abono-status", got)
220 if !strings.Contains(got, c.text) {
221 t.Errorf("status lacks the catalog text %q:\n%s", c.text, got)
222 }
223 wantDate := s.Date != nil
224 if wantDate != c.hasDate {
225 t.Fatalf("case says hasDate=%v, Describe gave %v", c.hasDate, s.Date)
226 }
227 if c.hasDate {
228 want := `<time datetime="` + s.Date.Format("2006-01-02") + `">` + s.Date.Format("2006-01-02") + `</time>`
229 if !strings.Contains(got, want) {
230 t.Errorf("status lacks %s:\n%s", want, got)
231 }
232 if strings.Contains(got, "{date}") {
233 t.Errorf("status left the {date} placeholder:\n%s", got)
234 }
235 }
236 })
237 }
238 for _, key := range []string{abono.StatusActive, abono.StatusRenews, abono.StatusCancelling, abono.StatusFailing, abono.StatusEnded, abono.StatusComp, abono.StatusCompUntil} {
239 if !seen[key] {
240 t.Errorf("no case renders %s", key)
241 }
242 }
243}
244
245var (
246 pillText = regexp.MustCompile(`^<span rst-status rst-tone="([a-z]+)">(.+)</span>$`)
247 anyTag = regexp.MustCompile(`<[^>]*>`)
248)
249
250// An app that formats dates itself passes DateText, and it is what the
251// reader sees; the machine-readable date stays in the time element. The
252// catalog's own text and the app's are escaped, never trusted as markup.
253func TestStatusShowsTheAppsDateText(t *testing.T) {
254 cat := rastrillo.Catalog{}
255 for k, v := range testCatalog {
256 cat[k] = v
257 }
258 cat["abono.status.renews"] = "Back & <i>soon</i>: {date}!"
259 l := testLocalizer(t, cat)
260 h := holdingCases()[1].h
261 got := render(t, "abono-status", map[string]any{"L": l, "Holding": h, "Now": nowAt, "DateText": "3 <November>"})
262 want := `Back &amp; &lt;i&gt;soon&lt;/i&gt;: <time datetime="2026-11-03">3 &lt;November&gt;</time>!`
263 if !strings.Contains(got, want) {
264 t.Errorf("want %s in:\n%s", want, got)
265 }
266 if strings.Contains(got, ">2026-11-03<") {
267 t.Errorf("ISO date shown beside the app's text:\n%s", got)
268 }
269 // Without DateText, the ISO date is the text.
270 got = render(t, "abono-status", map[string]any{"L": l, "Holding": h, "Now": nowAt})
271 if want := `<time datetime="2026-11-03">2026-11-03</time>!`; !strings.Contains(got, want) {
272 t.Errorf("want %s in:\n%s", want, got)
273 }
274}
275
276// A status is a word first. Every tone's pill carries visible text, so the
277// colour is never the only thing that says it.
278func TestStatusIsNeverColourAlone(t *testing.T) {
279 l := testLocalizer(t, testCatalog)
280 tones := map[string]bool{}
281 for _, c := range holdingCases() {
282 got := render(t, "abono-status", map[string]any{"L": l, "Holding": c.h, "Now": nowAt})
283 m := pillText.FindStringSubmatch(strings.TrimSpace(got))
284 if m == nil {
285 t.Errorf("%s: not a status pill with text: %s", c.name, got)
286 continue
287 }
288 tones[m[1]] = true
289 if m[1] != c.tone {
290 t.Errorf("%s: tone %q, want %q", c.name, m[1], c.tone)
291 }
292 if strings.TrimSpace(anyTag.ReplaceAllString(m[2], "")) == "" {
293 t.Errorf("%s: pill has no label", c.name)
294 }
295 }
296 for _, tone := range []string{"positive", "warning", "neutral"} {
297 if !tones[tone] {
298 t.Errorf("no case covers tone %q", tone)
299 }
300 }
301}
302
303func TestPlanCard(t *testing.T) {
304 l := testLocalizer(t, testCatalog)
305 card := func(o abono.Offer, interval string, code bool) string {
306 return render(t, "abono-plan-card", map[string]any{"L": l, "Offer": o, "Interval": interval, "Action": "/subscribe", "Code": code})
307 }
308
309 got := card(supporter(), "year", false)
310 for _, want := range []string{"Supporter", "T-PERYEAR", "€50.00", `action="/subscribe"`, `name="plan" value="supporter"`, `name="interval" value="year"`, "T-SUBSCRIBE"} {
311 if !strings.Contains(got, want) {
312 t.Errorf("year card lacks %q:\n%s", want, got)
313 }
314 }
315 // The retired price is not for sale; the month price is another card's.
316 for _, bad := range []string{"€40.00", "€5.00", "T-PERMONTH", "T-CODELABEL", `name="code"`} {
317 if strings.Contains(got, bad) {
318 t.Errorf("year card shows %q:\n%s", bad, got)
319 }
320 }
321
322 if got := card(supporter(), "month", false); !strings.Contains(got, "T-PERMONTH") || strings.Contains(got, "T-PERYEAR") {
323 t.Errorf("month card: %s", got)
324 }
325
326 // An offer with nothing sold at the interval says so, and sells nothing.
327 got = card(yearlyOnly(), "month", true)
328 if !strings.Contains(got, "T-NOINTERVAL") {
329 t.Errorf("unavailable interval not said: %s", got)
330 }
331 if strings.Contains(got, "<form") || strings.Contains(got, "<button") {
332 t.Errorf("a card with nothing to sell offers a button: %s", got)
333 }
334
335 got = card(supporter(), "year", true)
336 for _, want := range []string{"T-CODELABEL", "T-CODEHINT", `name="code"`} {
337 if !strings.Contains(got, want) {
338 t.Errorf("card with a code lacks %q:\n%s", want, got)
339 }
340 }
341 // The label is tied to its input, and the ids are the plan's, so two
342 // cards on a page do not share one.
343 if !regexp.MustCompile(`<label[^>]* for="(abono-code-[^"]+)"`).MatchString(got) {
344 t.Errorf("code label is not for an abono-code input: %s", got)
345 }
346}
347
348// TestUnsupportedPricesAreNeverOffered is final review I7 (ruling R39):
349// a price billed other than every month or every year is not shown or sold
350// by the card or the picker, whatever its interval says.
351func TestUnsupportedPricesAreNeverOffered(t *testing.T) {
352 l := testLocalizer(t, testCatalog)
353 odd := abono.Offer{Plan: "dozen", Name: "Dozen", Prices: []abono.Price{dozen}}
354 got := render(t, "abono-plan-card", map[string]any{"L": l, "Offer": odd, "Interval": "month", "Action": "/subscribe"})
355 if strings.Contains(got, "€120.00") || strings.Contains(got, "T-PERMONTH") || strings.Contains(got, "<form") {
356 t.Errorf("a card sells a price billed every twelve months as monthly:\n%s", got)
357 }
358 if !strings.Contains(got, "T-NOINTERVAL") {
359 t.Errorf("the card does not say the plan is not sold that way:\n%s", got)
360 }
361 got = render(t, "abono-plan-picker", map[string]any{"L": l, "Offers": []abono.Offer{odd}, "Action": "/subscribe"})
362 if strings.TrimSpace(got) != "" {
363 t.Errorf("the picker offers an unsupported price:\n%s", got)
364 }
365 got = render(t, "abono-plan-picker", map[string]any{"L": l, "Offers": []abono.Offer{odd, yearlyOnly()}, "Action": "/subscribe"})
366 if strings.Contains(got, "Dozen") || strings.Contains(got, "T-MONTHLY") {
367 t.Errorf("the picker shows the unsupported plan or a monthly group:\n%s", got)
368 }
369 for _, c := range []struct {
370 p abono.Price
371 want bool
372 }{
373 {dozen, true}, {monthly, false}, {yearly, false},
374 {abono.Price{Interval: "week", IntervalCount: 1}, true},
375 {abono.Price{Interval: "year", IntervalCount: 2}, true},
376 {abono.Price{}, false}, // not read from Stripe yet, or a one-off
377 } {
378 if got := c.p.Unsupported(); got != c.want {
379 t.Errorf("%+v.Unsupported() = %v, want %v", c.p, got, c.want)
380 }
381 }
382}
383
384// A key documented as optional may be left out, in either shape of data
385// a partial takes: a map without it, or a struct without the field.
386func TestOptionalKeysMayBeOmitted(t *testing.T) {
387 l := testLocalizer(t, testCatalog)
388 type card struct {
389 L *ui.Localizer
390 Offer abono.Offer
391 Action string
392 }
393 type paywallIn struct {
394 L *ui.Localizer
395 Offers []abono.Offer
396 SignedIn bool
397 Action string
398 }
399 type paywallOut struct {
400 L *ui.Localizer
401 Offers []abono.Offer
402 SignedIn bool
403 }
404 type button struct {
405 L *ui.Localizer
406 Action string
407 Plan string
408 }
409 oneSold := yearlyOnly()
410 for name, c := range map[string]struct {
411 partial string
412 data any
413 want []string
414 not []string
415 }{
416 "card, map": {"abono-plan-card", map[string]any{"L": l, "Offer": oneSold, "Action": "/s"},
417 []string{"T-PERYEAR", "T-SUBSCRIBE", `value="patron"`}, []string{"T-NOINTERVAL", `name="interval"`, `name="code"`}},
418 "card, struct": {"abono-plan-card", card{l, oneSold, "/s"},
419 []string{"T-PERYEAR", "T-SUBSCRIBE", `value="patron"`}, []string{"T-NOINTERVAL", `name="interval"`, `name="code"`}},
420 "paywall in, map": {"abono-paywall", map[string]any{"L": l, "Offers": []abono.Offer{oneSold}, "SignedIn": true, "Action": "/s"},
421 []string{"T-PAYWALL-TITLE", `action="/s"`}, []string{"T-SEEPLANS", "T-SIGNIN"}},
422 "paywall in, struct": {"abono-paywall", paywallIn{l, []abono.Offer{oneSold}, true, "/s"},
423 []string{"T-PAYWALL-TITLE", `action="/s"`}, []string{"T-SEEPLANS", "T-SIGNIN"}},
424 "paywall out, map": {"abono-paywall", map[string]any{"L": l, "Offers": []abono.Offer{oneSold}, "SignedIn": false},
425 []string{"T-SIGNIN-PROMPT", "T-SIGNIN"}, []string{"<form"}},
426 "paywall out, struct": {"abono-paywall", paywallOut{l, []abono.Offer{oneSold}, false},
427 []string{"T-SIGNIN-PROMPT", "T-SIGNIN"}, []string{"<form"}},
428 "button, map": {"abono-subscribe-button", map[string]any{"L": l, "Action": "/s", "Plan": "p"},
429 []string{"T-SUBSCRIBE", `value="p"`}, []string{`name="interval"`, `name="code"`}},
430 "button, struct": {"abono-subscribe-button", button{l, "/s", "p"},
431 []string{"T-SUBSCRIBE", `value="p"`}, []string{`name="interval"`, `name="code"`}},
432 "status, map": {"abono-status", map[string]any{"L": l, "Holding": holdingCases()[1].h, "Now": nowAt},
433 []string{"T-RENEWS", "2026-11-03"}, nil},
434 "status, struct": {"abono-status", struct {
435 L *ui.Localizer
436 Holding abono.Holding
437 Now time.Time
438 }{l, holdingCases()[1].h, nowAt},
439 []string{"T-RENEWS", "2026-11-03"}, nil},
440 } {
441 t.Run(name, func(t *testing.T) {
442 got := render(t, c.partial, c.data)
443 for _, w := range c.want {
444 if !strings.Contains(got, w) {
445 t.Errorf("lacks %q:\n%s", w, got)
446 }
447 }
448 for _, w := range c.not {
449 if strings.Contains(got, w) {
450 t.Errorf("has %q:\n%s", w, got)
451 }
452 }
453 })
454 }
455}
456
457func TestPlanPicker(t *testing.T) {
458 l := testLocalizer(t, testCatalog)
459 got := render(t, "abono-plan-picker", map[string]any{"L": l, "Offers": []abono.Offer{supporter(), yearlyOnly()}, "Action": "/subscribe"})
460 for _, want := range []string{"T-SWITCH", "T-MONTHLY", "T-YEARLY", "Supporter", "Patron"} {
461 if !strings.Contains(got, want) {
462 t.Errorf("picker lacks %q:\n%s", want, got)
463 }
464 }
465 // Month comes first; Patron has no monthly price and is in the yearly group only.
466 if strings.Index(got, "T-MONTHLY") > strings.Index(got, "T-YEARLY") {
467 t.Errorf("monthly does not come before yearly: %s", got)
468 }
469 if n := strings.Count(got, "Patron"); n != 1 {
470 t.Errorf("Patron shown %d times, want once (yearly only): %s", n, got)
471 }
472 if n := strings.Count(got, "Supporter"); n != 2 {
473 t.Errorf("Supporter shown %d times, want twice (both intervals): %s", n, got)
474 }
475 if strings.Contains(got, "T-NOINTERVAL") {
476 t.Errorf("picker offers a plan at an interval it lacks: %s", got)
477 }
478
479 // With one interval on sale there is nothing to switch between.
480 got = render(t, "abono-plan-picker", map[string]any{"L": l, "Offers": []abono.Offer{yearlyOnly()}, "Action": "/subscribe"})
481 if strings.Contains(got, "T-MONTHLY") || strings.Contains(got, "T-SWITCH") {
482 t.Errorf("picker with one interval shows a switch: %s", got)
483 }
484
485 // Nothing for sale renders nothing.
486 if got := strings.TrimSpace(render(t, "abono-plan-picker", map[string]any{"L": l, "Offers": []abono.Offer{}, "Action": "/subscribe"})); got != "" {
487 t.Errorf("empty picker renders %q", got)
488 }
489}
490
491func TestPaywall(t *testing.T) {
492 l := testLocalizer(t, testCatalog)
493 paywall := func(signedIn bool, extra map[string]any) string {
494 d := map[string]any{"L": l, "Offers": []abono.Offer{supporter()}, "SignedIn": signedIn, "SigninURL": "/signin?next=/post", "Action": "/subscribe"}
495 for k, v := range extra {
496 d[k] = v
497 }
498 return render(t, "abono-paywall", d)
499 }
500
501 out := paywall(false, nil)
502 for _, want := range []string{"T-PAYWALL-TITLE", "T-PAYWALL-BODY", "T-SIGNIN-PROMPT", `href="/signin?next=/post"`, "T-SIGNIN"} {
503 if !strings.Contains(out, want) {
504 t.Errorf("signed-out paywall lacks %q:\n%s", want, out)
505 }
506 }
507 if strings.Contains(out, "<form") {
508 t.Errorf("a reader who is not signed in is offered a purchase: %s", out)
509 }
510
511 in := paywall(true, nil)
512 for _, want := range []string{"T-PAYWALL-TITLE", "T-PAYWALL-BODY", "T-YEARLY", `action="/subscribe"`} {
513 if !strings.Contains(in, want) {
514 t.Errorf("signed-in paywall lacks %q:\n%s", want, in)
515 }
516 }
517 if strings.Contains(in, "T-SIGNIN") {
518 t.Errorf("signed-in paywall asks to sign in: %s", in)
519 }
520
521 link := paywall(true, map[string]any{"PlansURL": "/plans"})
522 if !strings.Contains(link, `href="/plans"`) || !strings.Contains(link, "T-SEEPLANS") || strings.Contains(link, "<form") {
523 t.Errorf("paywall with a PlansURL should link to the plans: %s", link)
524 }
525}
526
527func TestButtonsArePostForms(t *testing.T) {
528 l := testLocalizer(t, testCatalog)
529 form := regexp.MustCompile(`^<form[^>]* method="post" action="([^"]+)"`)
530 for _, c := range []struct {
531 name string
532 data map[string]any
533 action string
534 want []string
535 }{
536 {"abono-subscribe-button", map[string]any{"L": l, "Action": "/subscribe", "Plan": "supporter", "Interval": "year", "Label": "Join"}, "/subscribe",
537 []string{"Join", `name="plan" value="supporter"`, `name="interval" value="year"`}},
538 {"abono-subscribe-button", map[string]any{"L": l, "Action": "/subscribe", "Plan": "supporter"}, "/subscribe",
539 []string{"T-SUBSCRIBE", `name="plan" value="supporter"`}},
540 {"abono-billing-button", map[string]any{"L": l, "Action": "/billing"}, "/billing", []string{"T-MANAGE"}},
541 {"abono-billing-button", map[string]any{"L": l, "Action": "/billing", "Customer": "cus_1"}, "/billing",
542 []string{"T-MANAGE", `<input type="hidden" name="customer" value="cus_1">`}},
543 } {
544 got := strings.TrimSpace(render(t, c.name, c.data))
545 m := form.FindStringSubmatch(got)
546 if m == nil || m[1] != c.action {
547 t.Errorf("%s: not a POST form to %s: %s", c.name, c.action, got)
548 }
549 if strings.Count(got, "<form") != 1 || !strings.Contains(got, `<button type="submit"`) || !strings.HasSuffix(got, "</form>") {
550 t.Errorf("%s: want one form ending in a submit button: %s", c.name, got)
551 }
552 for _, w := range c.want {
553 if !strings.Contains(got, w) {
554 t.Errorf("%s lacks %q: %s", c.name, w, got)
555 }
556 }
557 }
558 // Without an interval asked for, none is sent: Checkout takes the plan's only price.
559 if got := render(t, "abono-subscribe-button", map[string]any{"L": l, "Action": "/s", "Plan": "p"}); strings.Contains(got, `name="interval"`) {
560 t.Errorf("an empty interval is sent: %s", got)
561 }
562}
563
564// partialKeys are the abono.* keys the partials name, read from their
565// source.
566func partialKeys(t *testing.T) map[string]bool {
567 t.Helper()
568 keys := map[string]bool{}
569 re := regexp.MustCompile(`"(abono\.[a-z_.]+)"`)
570 err := fs.WalkDir(abono.Templates(), ".", func(p string, d fs.DirEntry, err error) error {
571 if err != nil || d.IsDir() {
572 return err
573 }
574 src, err := fs.ReadFile(abono.Templates(), p)
575 if err != nil {
576 return err
577 }
578 for _, m := range re.FindAllStringSubmatch(string(src), -1) {
579 keys[m[1]] = true
580 }
581 return nil
582 })
583 if err != nil {
584 t.Fatal(err)
585 }
586 return keys
587}
588
589// The keys of the agreed copy. A key outside this list is copy nobody
590// reviewed, so a partial that reaches for one fails here.
591var agreedKeys = []string{
592 "abono.status.active", "abono.status.renews", "abono.status.cancelling", "abono.status.failing",
593 "abono.status.ended", "abono.status.comp", "abono.status.comp_until",
594 "abono.price.per_month", "abono.price.per_year", "abono.price.monthly", "abono.price.yearly", "abono.price.switch_label",
595 "abono.plan.interval_unavailable", "abono.plan.code_label", "abono.plan.code_hint",
596 "abono.paywall.title", "abono.paywall.body", "abono.paywall.signin_prompt", "abono.paywall.signin",
597 "abono.button.subscribe", "abono.button.manage", "abono.button.see_plans",
598}
599
600func TestPartialsUseOnlyAgreedKeys(t *testing.T) {
601 agreed := map[string]bool{}
602 for _, k := range agreedKeys {
603 agreed[k] = true
604 if _, ok := testCatalog[k]; !ok {
605 t.Errorf("the test catalog lacks the agreed key %s", k)
606 }
607 }
608 for k := range testCatalog {
609 if !agreed[k] {
610 t.Errorf("the test catalog has %s, which is not an agreed key", k)
611 }
612 }
613 for k := range partialKeys(t) {
614 if !agreed[k] {
615 t.Errorf("a partial names %s, which is not an agreed key", k)
616 }
617 }
618}
619
620// keyLiteral finds a whole message key written as a string literal. A
621// prefix built up at run time ("abono.price.per_" + interval) ends in _ or
622// . and is not a key; the keys it builds are named by a partial.
623var keyLiteral = regexp.MustCompile(`"(abono\.[a-z][a-z_.]*[a-z])"`)
624
625// consumers are where message keys are used, other than the partials in
626// templates/: the Go that names them (describe.go's constants, the
627// sandbox pages in internal/fakestripe) and the starter pages. dir is
628// relative to the package; recursive walks below it; absent says the
629// directory may not exist yet.
630var consumers = []struct {
631 dir string
632 recursive bool
633 absent bool
634 exts []string
635}{
636 {"templates", false, false, []string{".html"}},
637 {".", false, false, []string{".go"}},
638 {"internal/fakestripe", true, false, []string{".go"}},
639 // The starter pages: the generator, and the pages and Go it writes,
640 // embedded as .tmpl. The example's copies are byte-for-byte these
641 // (cmd/abono's TestGeneratedPagesMatchTheExample).
642 {"cmd/abono", true, false, []string{".html", ".go", ".tmpl"}},
643}
644
645// usedKeys is every abono.* key a consumer names. A directory that cannot
646// be read, or a file that cannot be read, is an error; only a consumer
647// marked absent may be missing.
648func usedKeys(t *testing.T) map[string]bool {
649 t.Helper()
650 used := map[string]bool{}
651 for _, c := range consumers {
652 root := os.DirFS(c.dir)
653 if _, err := fs.Stat(root, "."); err != nil {
654 if c.absent && errors.Is(err, fs.ErrNotExist) {
655 continue
656 }
657 t.Fatalf("consumer %s: %v", c.dir, err)
658 }
659 err := fs.WalkDir(root, ".", func(p string, d fs.DirEntry, err error) error {
660 if err != nil {
661 return err
662 }
663 if d.IsDir() {
664 if p != "." && !c.recursive {
665 return fs.SkipDir
666 }
667 return nil
668 }
669 if strings.HasSuffix(p, "_test.go") || !slices.Contains(c.exts, path.Ext(p)) {
670 return nil
671 }
672 src, err := fs.ReadFile(root, p)
673 if err != nil {
674 return err
675 }
676 for _, m := range keyLiteral.FindAllStringSubmatch(string(src), -1) {
677 used[m[1]] = true
678 }
679 return nil
680 })
681 if err != nil {
682 t.Fatalf("consumer %s: %v", c.dir, err)
683 }
684 }
685 return used
686}
687
688func TestUsedKeysSeesEveryConsumer(t *testing.T) {
689 used := usedKeys(t)
690 for _, k := range []string{
691 abono.StatusFailing, "abono.paywall.title", // Go constant, partial
692 "abono.sandbox.pay", "abono.sandbox.portal_return", // the sandbox pages
693 "abono.pages.closed_body", "abono.error.free_plan", // a starter page, the starter pages' Go
694 } {
695 if !used[k] {
696 t.Errorf("usedKeys misses %s", k)
697 }
698 }
699 if used["abono.price.per_"] {
700 t.Error("usedKeys takes a run-time prefix for a key")
701 }
702}
703
704// Every key a consumer uses is in en.toml, and every key in en.toml is
705// used by one of them.
706func TestCatalogKeysAreUsedAndDefined(t *testing.T) {
707 cat := abono.Catalog()
708 if len(cat) == 0 {
709 t.Fatal("locales/en.toml has no messages")
710 }
711 used := usedKeys(t)
712 for k := range used {
713 if _, ok := cat[k]; !ok {
714 t.Errorf("%s is used but not in locales/en.toml", k)
715 }
716 }
717 for k := range cat {
718 if !used[k] {
719 t.Errorf("%s is in locales/en.toml but nothing uses it", k)
720 }
721 }
722}
723
724func TestNoEmDashesInCopy(t *testing.T) {
725 src, err := fs.ReadFile(osDir("locales"), "en.toml")
726 if err != nil {
727 t.Fatal(err)
728 }
729 if strings.ContainsAny(string(src), "\u2014\u2013") {
730 t.Error("locales/en.toml has an em or en dash")
731 }
732}
733
734// osDir is dir under the package directory, as an fs.FS.
735func osDir(dir string) fs.FS { return os.DirFS(dir) }
736