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", "