rastrillo / abono Public

abono: optional partial keys, and a catalog gate that sees every consumer

Summary

Optional keys in the plan card and paywall are read with opt, so an omitted key (or a struct without the field) behaves as empty. The catalog gate now counts keys from the partials, describe.go, the sandbox pages and the starter directory, and fails on any read or walk error except the starter directory being absent.
Agent notes
TestOptionalKeysMayBeOmitted runs ten cases over map and struct data. A key built at run time (abono.price.per_ + interval) is not counted as a key; the partials name per_month and per_year. Proven by filling en.toml temporarily with all 36 consumer keys, then with an unused key, a dash, a missing sandbox key and an unreadable file, each failing as it should.
claude agent, operated by paul@keymail.dev 92c9abe2161b460a4fa39e7e5f2dcfc3066de7e6 parent 4ff105a
3 files changed, +163 −19
  • templates/abono-paywall.html +1 −1
  • templates/abono-plan-card.html +1 −1
  • templates_test.go +161 −17

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./... -count=1
ok  	amadan.net/rastrillo/abono	0.620s
ok  	amadan.net/rastrillo/abono/abonotest	0.047s
ok  	amadan.net/rastrillo/abono/internal/fakestripe	0.015s
ok  	amadan.net/rastrillo/abono/internal/stripeapi	0.005s
=== race ===
CGO_ENABLED=1 go test ./... -race -count=1
ok  	amadan.net/rastrillo/abono	5.015s
ok  	amadan.net/rastrillo/abono/abonotest	1.339s
ok  	amadan.net/rastrillo/abono/internal/fakestripe	1.053s
ok  	amadan.net/rastrillo/abono/internal/stripeapi	1.016s
=== browser ===
browser: no browser test file yet, nothing to run
diff --git a/templates/abono-paywall.html b/templates/abono-paywall.html
index 0c4c710..8ac84f8 100644
--- a/templates/abono-paywall.html
+++ b/templates/abono-paywall.html
@@ -14,4 +14,4 @@
PlansURL string, optional — when set, a signed-in reader gets a
"see plans" link there instead of the picker inline
L *ui.Localizer */}}
-{{define "abono-paywall"}}{{$L := localizer $}}<section{{if or (not .SignedIn) (opt . "PlansURL")}} rst-box{{end}}><h2>{{T $L "abono.paywall.title"}}</h2><p>{{T $L "abono.paywall.body"}}</p>{{if .SignedIn}}{{with opt . "PlansURL"}}<p><a rst-btn="primary" href="{{.}}">{{T $L "abono.button.see_plans"}}</a></p>{{else}}{{template "abono-plan-picker" dict "L" $L "Offers" $.Offers "Action" $.Action}}{{end}}{{else}}<p>{{T $L "abono.paywall.signin_prompt"}}</p><p><a rst-btn="primary" href="{{.SigninURL}}">{{T $L "abono.paywall.signin"}}</a></p>{{end}}</section>{{end}}
+{{define "abono-paywall"}}{{$L := localizer $}}<section{{if or (not .SignedIn) (opt . "PlansURL")}} rst-box{{end}}><h2>{{T $L "abono.paywall.title"}}</h2><p>{{T $L "abono.paywall.body"}}</p>{{if .SignedIn}}{{with opt . "PlansURL"}}<p><a rst-btn="primary" href="{{.}}">{{T $L "abono.button.see_plans"}}</a></p>{{else}}{{template "abono-plan-picker" dict "L" $L "Offers" $.Offers "Action" (or (opt $ "Action") "")}}{{end}}{{else}}<p>{{T $L "abono.paywall.signin_prompt"}}</p><p><a rst-btn="primary" href="{{or (opt . "SigninURL") ""}}">{{T $L "abono.paywall.signin"}}</a></p>{{end}}</section>{{end}}
diff --git a/templates/abono-plan-card.html b/templates/abono-plan-card.html
index 10486fa..931f625 100644
--- a/templates/abono-plan-card.html
+++ b/templates/abono-plan-card.html
@@ -13,4 +13,4 @@
Action string, required — where the buy form posts
Code bool, optional — a promotion code field in the buy form
L *ui.Localizer */}}
-{{define "abono-plan-card"}}{{$L := localizer $}}{{$iv := .Interval}}{{$found := false}}<div rst-box><h3>{{or .Offer.Name .Offer.Plan}}</h3>{{range .Offer.Prices}}{{if and .Sold (or (eq $iv "") (eq .Interval $iv))}}{{$found = true}}{{if .Currency}}<p>{{template "abono-price" dict "L" $L "Price" .}}</p>{{end}}{{end}}{{end}}{{if $found}}{{template "abono-subscribe-button" dict "L" $L "Action" .Action "Plan" .Offer.Plan "Interval" $iv "Code" (opt . "Code")}}{{else}}<p>{{T $L "abono.plan.interval_unavailable"}}</p>{{end}}</div>{{end}}
+{{define "abono-plan-card"}}{{$L := localizer $}}{{$iv := or (opt . "Interval") ""}}{{$found := false}}<div rst-box><h3>{{or .Offer.Name .Offer.Plan}}</h3>{{range .Offer.Prices}}{{if and .Sold (or (eq $iv "") (eq .Interval $iv))}}{{$found = true}}{{if .Currency}}<p>{{template "abono-price" dict "L" $L "Price" .}}</p>{{end}}{{end}}{{end}}{{if $found}}{{template "abono-subscribe-button" dict "L" $L "Action" .Action "Plan" .Offer.Plan "Interval" $iv "Code" (opt . "Code")}}{{else}}<p>{{T $L "abono.plan.interval_unavailable"}}</p>{{end}}</div>{{end}}
diff --git a/templates_test.go b/templates_test.go
index 5d8f0f0..02acff9 100644
--- a/templates_test.go
+++ b/templates_test.go
@@ -1,10 +1,13 @@
package abono_test
import (
+ "errors"
"html/template"
"io/fs"
"os"
+ "path"
"regexp"
+ "slices"
"strings"
"testing"
"time"
@@ -340,6 +343,79 @@ func TestPlanCard(t *testing.T) {
}
}
+// A key documented as optional may be left out, in either shape of data
+// a partial takes: a map without it, or a struct without the field.
+func TestOptionalKeysMayBeOmitted(t *testing.T) {
+ l := testLocalizer(t, testCatalog)
+ type card struct {
+ L *ui.Localizer
+ Offer abono.Offer
+ Action string
+ }
+ type paywallIn struct {
+ L *ui.Localizer
+ Offers []abono.Offer
+ SignedIn bool
+ Action string
+ }
+ type paywallOut struct {
+ L *ui.Localizer
+ Offers []abono.Offer
+ SignedIn bool
+ }
+ type button struct {
+ L *ui.Localizer
+ Action string
+ Plan string
+ }
+ oneSold := yearlyOnly()
+ for name, c := range map[string]struct {
+ partial string
+ data any
+ want []string
+ not []string
+ }{
+ "card, map": {"abono-plan-card", map[string]any{"L": l, "Offer": oneSold, "Action": "/s"},
+ []string{"T-PERYEAR", "T-SUBSCRIBE", `value="patron"`}, []string{"T-NOINTERVAL", `name="interval"`, `name="code"`}},
+ "card, struct": {"abono-plan-card", card{l, oneSold, "/s"},
+ []string{"T-PERYEAR", "T-SUBSCRIBE", `value="patron"`}, []string{"T-NOINTERVAL", `name="interval"`, `name="code"`}},
+ "paywall in, map": {"abono-paywall", map[string]any{"L": l, "Offers": []abono.Offer{oneSold}, "SignedIn": true, "Action": "/s"},
+ []string{"T-PAYWALL-TITLE", `action="/s"`}, []string{"T-SEEPLANS", "T-SIGNIN"}},
+ "paywall in, struct": {"abono-paywall", paywallIn{l, []abono.Offer{oneSold}, true, "/s"},
+ []string{"T-PAYWALL-TITLE", `action="/s"`}, []string{"T-SEEPLANS", "T-SIGNIN"}},
+ "paywall out, map": {"abono-paywall", map[string]any{"L": l, "Offers": []abono.Offer{oneSold}, "SignedIn": false},
+ []string{"T-SIGNIN-PROMPT", "T-SIGNIN"}, []string{"<form"}},
+ "paywall out, struct": {"abono-paywall", paywallOut{l, []abono.Offer{oneSold}, false},
+ []string{"T-SIGNIN-PROMPT", "T-SIGNIN"}, []string{"<form"}},
+ "button, map": {"abono-subscribe-button", map[string]any{"L": l, "Action": "/s", "Plan": "p"},
+ []string{"T-SUBSCRIBE", `value="p"`}, []string{`name="interval"`, `name="code"`}},
+ "button, struct": {"abono-subscribe-button", button{l, "/s", "p"},
+ []string{"T-SUBSCRIBE", `value="p"`}, []string{`name="interval"`, `name="code"`}},
+ "status, map": {"abono-status", map[string]any{"L": l, "Holding": holdingCases()[1].h, "Now": nowAt},
+ []string{"T-RENEWS", "2026-11-03"}, nil},
+ "status, struct": {"abono-status", struct {
+ L *ui.Localizer
+ Holding abono.Holding
+ Now time.Time
+ }{l, holdingCases()[1].h, nowAt},
+ []string{"T-RENEWS", "2026-11-03"}, nil},
+ } {
+ t.Run(name, func(t *testing.T) {
+ got := render(t, c.partial, c.data)
+ for _, w := range c.want {
+ if !strings.Contains(got, w) {
+ t.Errorf("lacks %q:\n%s", w, got)
+ }
+ }
+ for _, w := range c.not {
+ if strings.Contains(got, w) {
+ t.Errorf("has %q:\n%s", w, got)
+ }
+ }
+ })
+ }
+}
+
func TestPlanPicker(t *testing.T) {
l := testLocalizer(t, testCatalog)
got := render(t, "abono-plan-picker", map[string]any{"L": l, "Offers": []abono.Offer{supporter(), yearlyOnly()}, "Action": "/subscribe"})
@@ -501,28 +577,96 @@ func TestPartialsUseOnlyAgreedKeys(t *testing.T) {
}
}
-// Every key the partials use, or Describe returns, is in en.toml, and
-// every key in en.toml is used: here, or by cmd/abono's templates.
+// 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 land with Task 9; until they do the directory is
+ // absent, and only that is allowed to be.
+ {"cmd/abono", true, true, []string{".html", ".go"}},
+}
+
+// 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.StatusFailingNoDate, "abono.paywall.title", // Go constant, partial
+ "abono.sandbox.pay", "abono.sandbox.portal_return", // the sandbox pages
+ } {
+ 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.Skip("Task 8b: approved copy pending")
}
- used := partialKeys(t)
- for _, k := range []string{abono.StatusActive, abono.StatusRenews, abono.StatusCancelling, abono.StatusFailing, abono.StatusFailingNoDate, abono.StatusEnded, abono.StatusComp, abono.StatusCompUntil} {
- used[k] = true
- }
- re := regexp.MustCompile(`"(abono\.[a-z_.]+)"`)
- _ = fs.WalkDir(osDir("cmd/abono"), ".", func(p string, d fs.DirEntry, err error) error {
- if err != nil || d.IsDir() || !strings.HasSuffix(p, ".html") {
- return nil
- }
- src, _ := fs.ReadFile(osDir("cmd/abono"), p)
- for _, m := range re.FindAllStringSubmatch(string(src), -1) {
- used[m[1]] = true
- }
- return nil
- })
+ used := usedKeys(t)
for k := range used {
if _, ok := cat[k]; !ok {
t.Errorf("%s is used but not in locales/en.toml", k)