package abono_test import ( "errors" "html/template" "io/fs" "os" "path" "regexp" "slices" "strings" "testing" "time" "amadan.net/rastrillo/rastrillo" _ "amadan.net/rastrillo/rastrillo/locales/en" "amadan.net/rastrillo/rastrillo/ui" "amadan.net/rastrillo/abono" ) // testCatalog is made-up English for these tests, one entry per message // key the partials use. It never ships: the approved English is the // subject of the copy review and lands in locales/en.toml, and these // strings only let a test see which key a partial reached for. var testCatalog = rastrillo.Catalog{ "abono.status.active": "T-ACTIVE", "abono.status.renews": "T-RENEWS {date}", "abono.status.cancelling": "T-CANCELLING {date}", "abono.status.failing": "T-FAILING {date}", "abono.status.ended": "T-ENDED", "abono.status.comp": "T-COMP", "abono.status.comp_until": "T-COMPUNTIL {date}", "abono.price.per_month": "T-PERMONTH {amount}", "abono.price.per_year": "T-PERYEAR {amount}", "abono.price.monthly": "T-MONTHLY", "abono.price.yearly": "T-YEARLY", "abono.price.switch_label": "T-SWITCH", "abono.plan.interval_unavailable": "T-NOINTERVAL", "abono.plan.code_label": "T-CODELABEL", "abono.plan.code_hint": "T-CODEHINT", "abono.paywall.title": "T-PAYWALL-TITLE", "abono.paywall.body": "T-PAYWALL-BODY", "abono.paywall.signin_prompt": "T-SIGNIN-PROMPT", "abono.paywall.signin": "T-SIGNIN", "abono.button.subscribe": "T-SUBSCRIBE", "abono.button.manage": "T-MANAGE", "abono.button.see_plans": "T-SEEPLANS", } // partialNames are the partials abono ships, each with data that renders it. var partialNames = []string{ "abono-status", "abono-plan-card", "abono-plan-picker", "abono-paywall", "abono-subscribe-button", "abono-billing-button", "abono-price", } // testLocalizer is a localizer over the framework's own English with // catalog merged in below it, the way an app merges abono.Catalog() into // the base catalog it hands rastrillo.NewLocales. func testLocalizer(t *testing.T, catalog rastrillo.Catalog) *ui.Localizer { t.Helper() base := rastrillo.BaseCatalog() for k, v := range catalog { base[k] = v } locs, err := rastrillo.NewLocales([]string{"en"}, "en", base, nil) if err != nil { t.Fatalf("NewLocales: %v", err) } return ui.NewLocalizer("en", "en", func(key string, args ...any) string { return locs.Tf("en", key, args...) }) } // partials parses ui's partials and abono's, as an app does. func partials(t *testing.T) *template.Template { t.Helper() tmpl, err := template.New("").Funcs(ui.Funcs()).ParseFS(ui.Templates(), "*.html") if err != nil { t.Fatalf("parse ui: %v", err) } if tmpl, err = tmpl.ParseFS(abono.Templates(), "*.html"); err != nil { t.Fatalf("parse abono: %v", err) } return tmpl } func render(t *testing.T, name string, data any) string { t.Helper() var b strings.Builder if err := partials(t).ExecuteTemplate(&b, name, data); err != nil { t.Fatalf("ExecuteTemplate(%q): %v", name, err) } return b.String() } var ( soon = time.Date(2026, 11, 3, 9, 30, 0, 0, time.UTC) nowAt = time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC) yearly = abono.Price{ID: "price_y", Sold: true, Interval: "year", Amount: 5000, Currency: "EUR"} monthly = abono.Price{ID: "price_m", Sold: true, Interval: "month", Amount: 500, Currency: "EUR"} oldYr = abono.Price{ID: "price_old", Sold: false, Interval: "year", Amount: 4000, Currency: "EUR"} // dozen is sold, monthly by Stripe's interval, but every twelve months: // v0.1 cannot sell it and must never show it as €120 a month. dozen = abono.Price{ID: "price_12m", Sold: true, Interval: "month", IntervalCount: 12, Amount: 12000, Currency: "EUR"} ) func supporter() abono.Offer { return abono.Offer{Plan: "supporter", Name: "Supporter", Prices: []abono.Price{monthly, yearly, oldYr}} } func yearlyOnly() abono.Offer { return abono.Offer{Plan: "patron", Name: "Patron", Prices: []abono.Price{yearly}} } // sample is data that renders each partial with every optional key set. func sample(l *ui.Localizer) map[string]map[string]any { holding := abono.Holding{Plan: "supporter", Source: abono.SourceSubscription, Status: "active", Until: &soon, Grants: true} return map[string]map[string]any{ "abono-status": {"L": l, "Holding": holding, "Now": nowAt}, "abono-plan-card": {"L": l, "Offer": supporter(), "Interval": "year", "Action": "/subscribe", "Code": true}, "abono-plan-picker": {"L": l, "Offers": []abono.Offer{supporter(), yearlyOnly()}, "Action": "/subscribe"}, "abono-paywall": {"L": l, "Offers": []abono.Offer{supporter()}, "SignedIn": true, "SigninURL": "/signin", "Action": "/subscribe"}, "abono-subscribe-button": {"L": l, "Action": "/subscribe", "Plan": "supporter", "Interval": "year", "Label": "Join"}, "abono-billing-button": {"L": l, "Action": "/billing"}, "abono-price": {"L": l, "Price": monthly}, } } // whole renders, so a missing key or a stray placeholder shows. func checkClean(t *testing.T, name, got string) { t.Helper() for _, bad := range []string{ "http://", "https://", "//cdn", "", "abono.", "ZgotmplZ", } { if strings.Contains(got, bad) { t.Errorf("partial %q renders %q:\n%s", name, bad, got) } } } func TestEveryPartialIsDefinedAndSelfContained(t *testing.T) { l := testLocalizer(t, testCatalog) data := sample(l) tmpl := partials(t) for _, name := range partialNames { if tmpl.Lookup(name) == nil { t.Errorf("partial %q is not defined by abono.Templates()", name) continue } d, ok := data[name] if !ok { t.Fatalf("no sample data for %q", name) } checkClean(t, name, render(t, name, d)) } } func TestTemplatesIsRootedAtThePartials(t *testing.T) { entries, err := fs.ReadDir(abono.Templates(), ".") if err != nil || len(entries) == 0 { t.Fatalf("ReadDir: %v, %d entries", err, len(entries)) } for _, e := range entries { if !strings.HasSuffix(e.Name(), ".html") { t.Errorf("unexpected entry %q at the root of Templates()", e.Name()) } } } // holdingCase is a holding that Describe reads as one status key. type holdingCase struct { name string h abono.Holding key string tone string text string // the test catalog's text hasDate bool } func holdingCases() []holdingCase { sub := func(status string, cancel bool) abono.Holding { return abono.Holding{Plan: "supporter", Source: abono.SourceSubscription, Status: status, Until: &soon, CancelAtPeriodEnd: cancel, Grants: true} } started := nowAt.Add(-48 * time.Hour) failing := sub("past_due", false) failing.PeriodStart = &started active := abono.Holding{Plan: "supporter", Source: abono.SourceSubscription, Status: "active", Grants: true} noStart := sub("past_due", false) // no period start: counted from the end compUntil := abono.Holding{Plan: "supporter", Source: abono.SourceComp, Status: "active", Until: &soon, Grants: true} return []holdingCase{ {"active", active, abono.StatusActive, "positive", "T-ACTIVE", false}, {"renews", sub("active", false), abono.StatusRenews, "positive", "T-RENEWS", true}, {"cancelling", sub("active", true), abono.StatusCancelling, "warning", "T-CANCELLING", true}, {"failing", failing, abono.StatusFailing, "warning", "T-FAILING", true}, {"failing, no period start", noStart, abono.StatusFailing, "warning", "T-FAILING", true}, {"ended", sub("canceled", false), abono.StatusEnded, "neutral", "T-ENDED", false}, {"comp", abono.Holding{Plan: "supporter", Source: abono.SourceComp, Status: "active", Grants: true}, abono.StatusComp, "positive", "T-COMP", false}, {"comp_until", compUntil, abono.StatusCompUntil, "positive", "T-COMPUNTIL", true}, } } // The date is written as the year-month-day it is, the one form that // reads the same in every language, until core has a date helper that // reads a time in the reader's locale. func TestStatusRendersEveryKey(t *testing.T) { l := testLocalizer(t, testCatalog) seen := map[string]bool{} for _, c := range holdingCases() { t.Run(c.name, func(t *testing.T) { s := abono.Describe(c.h, nowAt) if s.Key != c.key || s.Tone != c.tone { t.Fatalf("Describe = %q/%q, case wants %q/%q", s.Key, s.Tone, c.key, c.tone) } seen[s.Key] = true got := render(t, "abono-status", map[string]any{"L": l, "Holding": c.h, "Now": nowAt}) checkClean(t, "abono-status", got) if !strings.Contains(got, c.text) { t.Errorf("status lacks the catalog text %q:\n%s", c.text, got) } wantDate := s.Date != nil if wantDate != c.hasDate { t.Fatalf("case says hasDate=%v, Describe gave %v", c.hasDate, s.Date) } if c.hasDate { want := `` if !strings.Contains(got, want) { t.Errorf("status lacks %s:\n%s", want, got) } if strings.Contains(got, "{date}") { t.Errorf("status left the {date} placeholder:\n%s", got) } } }) } for _, key := range []string{abono.StatusActive, abono.StatusRenews, abono.StatusCancelling, abono.StatusFailing, abono.StatusEnded, abono.StatusComp, abono.StatusCompUntil} { if !seen[key] { t.Errorf("no case renders %s", key) } } } var ( pillText = regexp.MustCompile(`^(.+)$`) anyTag = regexp.MustCompile(`<[^>]*>`) ) // An app that formats dates itself passes DateText, and it is what the // reader sees; the machine-readable date stays in the time element. The // catalog's own text and the app's are escaped, never trusted as markup. func TestStatusShowsTheAppsDateText(t *testing.T) { cat := rastrillo.Catalog{} for k, v := range testCatalog { cat[k] = v } cat["abono.status.renews"] = "Back & soon: {date}!" l := testLocalizer(t, cat) h := holdingCases()[1].h got := render(t, "abono-status", map[string]any{"L": l, "Holding": h, "Now": nowAt, "DateText": "3 "}) want := `Back & <i>soon</i>: !` if !strings.Contains(got, want) { t.Errorf("want %s in:\n%s", want, got) } if strings.Contains(got, ">2026-11-03<") { t.Errorf("ISO date shown beside the app's text:\n%s", got) } // Without DateText, the ISO date is the text. got = render(t, "abono-status", map[string]any{"L": l, "Holding": h, "Now": nowAt}) if want := `!`; !strings.Contains(got, want) { t.Errorf("want %s in:\n%s", want, got) } } // A status is a word first. Every tone's pill carries visible text, so the // colour is never the only thing that says it. func TestStatusIsNeverColourAlone(t *testing.T) { l := testLocalizer(t, testCatalog) tones := map[string]bool{} for _, c := range holdingCases() { got := render(t, "abono-status", map[string]any{"L": l, "Holding": c.h, "Now": nowAt}) m := pillText.FindStringSubmatch(strings.TrimSpace(got)) if m == nil { t.Errorf("%s: not a status pill with text: %s", c.name, got) continue } tones[m[1]] = true if m[1] != c.tone { t.Errorf("%s: tone %q, want %q", c.name, m[1], c.tone) } if strings.TrimSpace(anyTag.ReplaceAllString(m[2], "")) == "" { t.Errorf("%s: pill has no label", c.name) } } for _, tone := range []string{"positive", "warning", "neutral"} { if !tones[tone] { t.Errorf("no case covers tone %q", tone) } } } func TestPlanCard(t *testing.T) { l := testLocalizer(t, testCatalog) card := func(o abono.Offer, interval string, code bool) string { return render(t, "abono-plan-card", map[string]any{"L": l, "Offer": o, "Interval": interval, "Action": "/subscribe", "Code": code}) } got := card(supporter(), "year", false) for _, want := range []string{"Supporter", "T-PERYEAR", "€50.00", `action="/subscribe"`, `name="plan" value="supporter"`, `name="interval" value="year"`, "T-SUBSCRIBE"} { if !strings.Contains(got, want) { t.Errorf("year card lacks %q:\n%s", want, got) } } // The retired price is not for sale; the month price is another card's. for _, bad := range []string{"€40.00", "€5.00", "T-PERMONTH", "T-CODELABEL", `name="code"`} { if strings.Contains(got, bad) { t.Errorf("year card shows %q:\n%s", bad, got) } } if got := card(supporter(), "month", false); !strings.Contains(got, "T-PERMONTH") || strings.Contains(got, "T-PERYEAR") { t.Errorf("month card: %s", got) } // An offer with nothing sold at the interval says so, and sells nothing. got = card(yearlyOnly(), "month", true) if !strings.Contains(got, "T-NOINTERVAL") { t.Errorf("unavailable interval not said: %s", got) } if strings.Contains(got, "]* for="(abono-code-[^"]+)"`).MatchString(got) { t.Errorf("code label is not for an abono-code input: %s", got) } } // TestUnsupportedPricesAreNeverOffered is final review I7 (ruling R39): // a price billed other than every month or every year is not shown or sold // by the card or the picker, whatever its interval says. func TestUnsupportedPricesAreNeverOffered(t *testing.T) { l := testLocalizer(t, testCatalog) odd := abono.Offer{Plan: "dozen", Name: "Dozen", Prices: []abono.Price{dozen}} got := render(t, "abono-plan-card", map[string]any{"L": l, "Offer": odd, "Interval": "month", "Action": "/subscribe"}) if strings.Contains(got, "€120.00") || strings.Contains(got, "T-PERMONTH") || strings.Contains(got, " strings.Index(got, "T-YEARLY") { t.Errorf("monthly does not come before yearly: %s", got) } if n := strings.Count(got, "Patron"); n != 1 { t.Errorf("Patron shown %d times, want once (yearly only): %s", n, got) } if n := strings.Count(got, "Supporter"); n != 2 { t.Errorf("Supporter shown %d times, want twice (both intervals): %s", n, got) } if strings.Contains(got, "T-NOINTERVAL") { t.Errorf("picker offers a plan at an interval it lacks: %s", got) } // With one interval on sale there is nothing to switch between. got = render(t, "abono-plan-picker", map[string]any{"L": l, "Offers": []abono.Offer{yearlyOnly()}, "Action": "/subscribe"}) if strings.Contains(got, "T-MONTHLY") || strings.Contains(got, "T-SWITCH") { t.Errorf("picker with one interval shows a switch: %s", got) } // Nothing for sale renders nothing. if got := strings.TrimSpace(render(t, "abono-plan-picker", map[string]any{"L": l, "Offers": []abono.Offer{}, "Action": "/subscribe"})); got != "" { t.Errorf("empty picker renders %q", got) } } func TestPaywall(t *testing.T) { l := testLocalizer(t, testCatalog) paywall := func(signedIn bool, extra map[string]any) string { d := map[string]any{"L": l, "Offers": []abono.Offer{supporter()}, "SignedIn": signedIn, "SigninURL": "/signin?next=/post", "Action": "/subscribe"} for k, v := range extra { d[k] = v } return render(t, "abono-paywall", d) } out := paywall(false, nil) for _, want := range []string{"T-PAYWALL-TITLE", "T-PAYWALL-BODY", "T-SIGNIN-PROMPT", `href="/signin?next=/post"`, "T-SIGNIN"} { if !strings.Contains(out, want) { t.Errorf("signed-out paywall lacks %q:\n%s", want, out) } } if strings.Contains(out, "]* method="post" action="([^"]+)"`) for _, c := range []struct { name string data map[string]any action string want []string }{ {"abono-subscribe-button", map[string]any{"L": l, "Action": "/subscribe", "Plan": "supporter", "Interval": "year", "Label": "Join"}, "/subscribe", []string{"Join", `name="plan" value="supporter"`, `name="interval" value="year"`}}, {"abono-subscribe-button", map[string]any{"L": l, "Action": "/subscribe", "Plan": "supporter"}, "/subscribe", []string{"T-SUBSCRIBE", `name="plan" value="supporter"`}}, {"abono-billing-button", map[string]any{"L": l, "Action": "/billing"}, "/billing", []string{"T-MANAGE"}}, {"abono-billing-button", map[string]any{"L": l, "Action": "/billing", "Customer": "cus_1"}, "/billing", []string{"T-MANAGE", ``}}, } { got := strings.TrimSpace(render(t, c.name, c.data)) m := form.FindStringSubmatch(got) if m == nil || m[1] != c.action { t.Errorf("%s: not a POST form to %s: %s", c.name, c.action, got) } if strings.Count(got, "") { t.Errorf("%s: want one form ending in a submit button: %s", c.name, got) } for _, w := range c.want { if !strings.Contains(got, w) { t.Errorf("%s lacks %q: %s", c.name, w, got) } } } // Without an interval asked for, none is sent: Checkout takes the plan's only price. if got := render(t, "abono-subscribe-button", map[string]any{"L": l, "Action": "/s", "Plan": "p"}); strings.Contains(got, `name="interval"`) { t.Errorf("an empty interval is sent: %s", got) } } // partialKeys are the abono.* keys the partials name, read from their // source. func partialKeys(t *testing.T) map[string]bool { t.Helper() keys := map[string]bool{} re := regexp.MustCompile(`"(abono\.[a-z_.]+)"`) err := fs.WalkDir(abono.Templates(), ".", func(p string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } src, err := fs.ReadFile(abono.Templates(), p) if err != nil { return err } for _, m := range re.FindAllStringSubmatch(string(src), -1) { keys[m[1]] = true } return nil }) if err != nil { t.Fatal(err) } return keys } // The keys of the agreed copy. A key outside this list is copy nobody // reviewed, so a partial that reaches for one fails here. var agreedKeys = []string{ "abono.status.active", "abono.status.renews", "abono.status.cancelling", "abono.status.failing", "abono.status.ended", "abono.status.comp", "abono.status.comp_until", "abono.price.per_month", "abono.price.per_year", "abono.price.monthly", "abono.price.yearly", "abono.price.switch_label", "abono.plan.interval_unavailable", "abono.plan.code_label", "abono.plan.code_hint", "abono.paywall.title", "abono.paywall.body", "abono.paywall.signin_prompt", "abono.paywall.signin", "abono.button.subscribe", "abono.button.manage", "abono.button.see_plans", } func TestPartialsUseOnlyAgreedKeys(t *testing.T) { agreed := map[string]bool{} for _, k := range agreedKeys { agreed[k] = true if _, ok := testCatalog[k]; !ok { t.Errorf("the test catalog lacks the agreed key %s", k) } } for k := range testCatalog { if !agreed[k] { t.Errorf("the test catalog has %s, which is not an agreed key", k) } } for k := range partialKeys(t) { if !agreed[k] { t.Errorf("a partial names %s, which is not an agreed key", k) } } } // keyLiteral finds a whole message key written as a string literal. A // prefix built up at run time ("abono.price.per_" + interval) ends in _ or // . and is not a key; the keys it builds are named by a partial. var keyLiteral = regexp.MustCompile(`"(abono\.[a-z][a-z_.]*[a-z])"`) // consumers are where message keys are used, other than the partials in // templates/: the Go that names them (describe.go's constants, the // sandbox pages in internal/fakestripe) and the starter pages. dir is // relative to the package; recursive walks below it; absent says the // directory may not exist yet. var consumers = []struct { dir string recursive bool absent bool exts []string }{ {"templates", false, false, []string{".html"}}, {".", false, false, []string{".go"}}, {"internal/fakestripe", true, false, []string{".go"}}, // The starter pages: the generator, and the pages and Go it writes, // embedded as .tmpl. The example's copies are byte-for-byte these // (cmd/abono's TestGeneratedPagesMatchTheExample). {"cmd/abono", true, false, []string{".html", ".go", ".tmpl"}}, } // usedKeys is every abono.* key a consumer names. A directory that cannot // be read, or a file that cannot be read, is an error; only a consumer // marked absent may be missing. func usedKeys(t *testing.T) map[string]bool { t.Helper() used := map[string]bool{} for _, c := range consumers { root := os.DirFS(c.dir) if _, err := fs.Stat(root, "."); err != nil { if c.absent && errors.Is(err, fs.ErrNotExist) { continue } t.Fatalf("consumer %s: %v", c.dir, err) } err := fs.WalkDir(root, ".", func(p string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { if p != "." && !c.recursive { return fs.SkipDir } return nil } if strings.HasSuffix(p, "_test.go") || !slices.Contains(c.exts, path.Ext(p)) { return nil } src, err := fs.ReadFile(root, p) if err != nil { return err } for _, m := range keyLiteral.FindAllStringSubmatch(string(src), -1) { used[m[1]] = true } return nil }) if err != nil { t.Fatalf("consumer %s: %v", c.dir, err) } } return used } func TestUsedKeysSeesEveryConsumer(t *testing.T) { used := usedKeys(t) for _, k := range []string{ abono.StatusFailing, "abono.paywall.title", // Go constant, partial "abono.sandbox.pay", "abono.sandbox.portal_return", // the sandbox pages "abono.pages.closed_body", "abono.error.free_plan", // a starter page, the starter pages' Go } { if !used[k] { t.Errorf("usedKeys misses %s", k) } } if used["abono.price.per_"] { t.Error("usedKeys takes a run-time prefix for a key") } } // Every key a consumer uses is in en.toml, and every key in en.toml is // used by one of them. func TestCatalogKeysAreUsedAndDefined(t *testing.T) { cat := abono.Catalog() if len(cat) == 0 { t.Fatal("locales/en.toml has no messages") } used := usedKeys(t) for k := range used { if _, ok := cat[k]; !ok { t.Errorf("%s is used but not in locales/en.toml", k) } } for k := range cat { if !used[k] { t.Errorf("%s is in locales/en.toml but nothing uses it", k) } } } func TestNoEmDashesInCopy(t *testing.T) { src, err := fs.ReadFile(osDir("locales"), "en.toml") if err != nil { t.Fatal(err) } if strings.ContainsAny(string(src), "\u2014\u2013") { t.Error("locales/en.toml has an em or en dash") } } // osDir is dir under the package directory, as an fs.FS. func osDir(dir string) fs.FS { return os.DirFS(dir) }