abono: the last approved copy, the example's own catalog, and an email field in the sandbox
Summary
Writes the ten approved abono.* strings (review 2 and 3, bar the unused failing_nodate) into locales/en.toml byte for byte and removes the pendingReview mechanism from the catalog gate. The example gets its own catalog (example/locales/en.toml, example.signout) merged after abono.Catalog(), with a gate that holds example.* keys to it. The sandbox checkout asks a signed-out tester for an email address (label abono.sandbox.email) and uses it as the Checkout customer's email; an empty or malformed address re-renders the page with 422 and buys nothing. Signed-in testers (a customer, address or subject on the session) are not asked.
Agent notes
Ruling R29. A subject-only signed-in session now gets a customer with no email instead of buyer_N@example.com; the subject identifies them. The 422 page shows no error text because none is approved: the field is required and type=email, and carries aria-invalid. example/README.md still says the words are keys until the catalog lands; left alone as it is prose that needs copy review.
10 files changed,
+415
−49
example/app.go+3 −3example/catalog.go+57 −0example/catalog_test.go+108 −0example/locales/en.toml+5 −0internal/fakestripe/fake.go+14 −5internal/fakestripe/fake_test.go+119 −0internal/fakestripe/pages.go+36 −3locales/en.toml+10 −0sandbox_test.go+59 −1templates_test.go+4 −37
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... -count=1 ok amadan.net/rastrillo/abono 2.665s ok amadan.net/rastrillo/abono/abonotest 0.061s ok amadan.net/rastrillo/abono/cmd/abono 0.749s ok amadan.net/rastrillo/abono/internal/fakestripe 0.023s ok amadan.net/rastrillo/abono/internal/stripeapi 0.006s ok example 0.256s === race === CGO_ENABLED=1 go test ./... -race -count=1 ok amadan.net/rastrillo/abono 9.785s ok amadan.net/rastrillo/abono/abonotest 1.405s ok amadan.net/rastrillo/abono/cmd/abono 1.981s ok amadan.net/rastrillo/abono/internal/fakestripe 1.082s ok amadan.net/rastrillo/abono/internal/stripeapi 1.019s ok example 2.614s === browser === ok example 0.755s
Intent
diff --git a/example/app.go b/example/app.go| index 1d75c08..f3da4ee 100644 |
| --- a/example/app.go |
| +++ b/example/app.go |
| @@ -56,7 +56,7 @@ type Config struct { |
| // Abono, when set, adjusts abono's configuration last (tests point |
| // it at abonotest's Stripe with abonotest.Stripe.Config). |
| Abono func(abono.Config) abono.Config |
| - // Catalog is the base message catalog; abono.Catalog() when nil. |
| + // Catalog is the base message catalog; the example's Catalog() when nil. |
| Catalog rastrillo.Catalog |
| Logger *slog.Logger |
| } |
| @@ -68,7 +68,7 @@ func Options(cfg Config) (rastrillo.Options, error) { |
| cfg.Logger = slog.Default() |
| } |
| if cfg.Catalog == nil { |
| - cfg.Catalog = abono.Catalog() |
| + cfg.Catalog = Catalog() |
| } |
| mux, err := App(cfg) |
| if err != nil { |
| @@ -91,7 +91,7 @@ func App(cfg Config) (*http.ServeMux, error) { |
| ctx := context.Background() |
| logger := cfg.Logger |
| if cfg.Catalog == nil { |
| - cfg.Catalog = abono.Catalog() |
| + cfg.Catalog = Catalog() |
| } |
| if _, err := migrate.Apply(ctx, cfg.DB, BootSchema); err != nil { |
| return nil, err |
diff --git a/example/catalog.go b/example/catalog.go| new file mode 100644 |
| index 0000000..04d77d5 |
| --- /dev/null |
| +++ b/example/catalog.go |
| @@ -0,0 +1,57 @@ |
| +package main |
| + |
| +import ( |
| + "embed" |
| + "fmt" |
| + "strconv" |
| + "strings" |
| + |
| + "amadan.net/rastrillo/rastrillo" |
| + |
| + "amadan.net/rastrillo/abono" |
| +) |
| + |
| +//go:embed locales/en.toml |
| +var localesFS embed.FS |
| + |
| +// Catalog is the base message catalog the example runs on: abono's |
| +// English, then the example's own (locales/en.toml, the "example.*" keys |
| +// its layout and pages use). A key belongs to the module that renders it, |
| +// so abono's catalog has none of these. |
| +func Catalog() rastrillo.Catalog { |
| + cat := abono.Catalog() |
| + own, err := ownCatalog() |
| + if err != nil { |
| + panic(fmt.Sprintf("example: locales/en.toml: %v", err)) // a build bug |
| + } |
| + for k, v := range own { |
| + cat[k] = v |
| + } |
| + return cat |
| +} |
| + |
| +// ownCatalog is locales/en.toml: one key = "value" per line, with # |
| +// comments and blank lines, the flat format rastrillo's catalogs use. |
| +func ownCatalog() (rastrillo.Catalog, error) { |
| + data, err := localesFS.ReadFile("locales/en.toml") |
| + if err != nil { |
| + return nil, err |
| + } |
| + out := rastrillo.Catalog{} |
| + for i, raw := range strings.Split(string(data), "\n") { |
| + line := strings.TrimSpace(raw) |
| + if line == "" || strings.HasPrefix(line, "#") { |
| + continue |
| + } |
| + key, val, ok := strings.Cut(line, " = ") |
| + text, err := strconv.Unquote(strings.TrimSpace(val)) |
| + if !ok || key == "" || err != nil { |
| + return nil, fmt.Errorf("line %d: want key = \"value\"", i+1) |
| + } |
| + if _, dup := out[key]; dup { |
| + return nil, fmt.Errorf("line %d: duplicate key %q", i+1, key) |
| + } |
| + out[key] = text |
| + } |
| + return out, nil |
| +} |
diff --git a/example/catalog_test.go b/example/catalog_test.go| new file mode 100644 |
| index 0000000..df58864 |
| --- /dev/null |
| +++ b/example/catalog_test.go |
| @@ -0,0 +1,108 @@ |
| +package main |
| + |
| +import ( |
| + "io/fs" |
| + "net/http" |
| + "os" |
| + "path" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/abono" |
| +) |
| + |
| +// exampleKeys is every "example.*" key the example's own templates and Go |
| +// name. The generated pages (abono_pages.go, templates/abono/) are abono's |
| +// and use abono.* keys, which its own gate holds to its catalog; main.go |
| +// is configuration, where "example.db" is a file name, not a key. |
| +func exampleKeys(t *testing.T) map[string]bool { |
| + t.Helper() |
| + used := map[string]bool{} |
| + root := os.DirFS(".") |
| + err := fs.WalkDir(root, ".", func(p string, d fs.DirEntry, err error) error { |
| + if err != nil { |
| + return err |
| + } |
| + if d.IsDir() { |
| + if p == "templates/abono" || p == "migrations" { |
| + return fs.SkipDir |
| + } |
| + return nil |
| + } |
| + if ext := path.Ext(p); (ext != ".html" && ext != ".go") || strings.HasSuffix(p, "_test.go") || p == "abono_pages.go" || p == "main.go" { |
| + return nil |
| + } |
| + src, err := fs.ReadFile(root, p) |
| + if err != nil { |
| + return err |
| + } |
| + for _, m := range keyLiteral.FindAllSubmatch(src, -1) { |
| + if k := string(m[1]); strings.HasPrefix(k, "example.") { |
| + used[k] = true |
| + } |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return used |
| +} |
| + |
| +// The example's "example.*" keys and locales/en.toml agree: every key its |
| +// templates use is defined there, every key there is used, and the file |
| +// holds nothing but example.* keys (abono's belong to abono's catalog). |
| +func TestExampleCatalogCoversTheKeysTheExampleUses(t *testing.T) { |
| + own, err := ownCatalog() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + used := exampleKeys(t) |
| + if !used["example.signout"] { |
| + t.Error("exampleKeys misses example.signout, which the layout uses") |
| + } |
| + for k := range used { |
| + if _, ok := own[k]; !ok { |
| + t.Errorf("%s is used but not in locales/en.toml", k) |
| + } |
| + } |
| + for k := range own { |
| + if !used[k] { |
| + t.Errorf("%s is in locales/en.toml but nothing uses it", k) |
| + } |
| + if !strings.HasPrefix(k, "example.") { |
| + t.Errorf("%s is in the example's catalog but is not an example.* key", k) |
| + } |
| + } |
| + for k := range abono.Catalog() { |
| + if strings.HasPrefix(k, "example.") { |
| + t.Errorf("abono's catalog has %s, which is the example's", k) |
| + } |
| + } |
| + merged := Catalog() |
| + for k, v := range abono.Catalog() { |
| + if merged[k] != v { |
| + t.Errorf("Catalog() changed abono's %s", k) |
| + } |
| + } |
| + for k, v := range own { |
| + if merged[k] != v { |
| + t.Errorf("Catalog() lacks the example's %s", k) |
| + } |
| + } |
| +} |
| + |
| +// With no catalog given, the example runs on its own: a signed-in page |
| +// says "Sign out", not the key. |
| +func TestExampleSaysSignOutInItsOwnCatalog(t *testing.T) { |
| + app := newApp(t, func(c *Config) { c.Catalog = nil }) |
| + reader := newClient(t, app) |
| + reader.signin("reader@example.com") |
| + status, body := reader.get("/subscribe") |
| + if status != http.StatusOK { |
| + t.Fatalf("GET /subscribe: %d", status) |
| + } |
| + if !strings.Contains(body, ">Sign out<") || strings.Contains(body, "example.signout") { |
| + t.Errorf("the nav does not say Sign out:\n%s", body) |
| + } |
| +} |
diff --git a/example/locales/en.toml b/example/locales/en.toml| new file mode 100644 |
| index 0000000..1731a9d |
| --- /dev/null |
| +++ b/example/locales/en.toml |
| @@ -0,0 +1,5 @@ |
| +# The example's own English: the words its layout and pages use that abono does not. |
| +# abono's are in abono.Catalog(); a key belongs to the module that renders it. |
| + |
| +# The layout. |
| +example.signout = "Sign out" |
diff --git a/internal/fakestripe/fake.go b/internal/fakestripe/fake.go| index fbd0904..745d790 100644 |
| --- a/internal/fakestripe/fake.go |
| +++ b/internal/fakestripe/fake.go |
| @@ -596,7 +596,9 @@ func (f *Fake) promotionCodes(w http.ResponseWriter, r *http.Request) { |
| } |
| // pay completes session id as a card payment that went through: a |
| -// customer (the one Checkout was given, else a new one with email), an |
| +// customer (the one Checkout was given, else a new one with the address |
| +// Checkout was given or, for someone with no subject either, email, which |
| +// must be an address: errEmail and nothing bought when it is not), an |
| // active subscription on the session's price for one period, and the |
| // session complete and paid. It returns where Checkout sends the buyer. |
| // Paying a session already paid buys nothing more and returns the same |
| @@ -619,11 +621,17 @@ func (f *Fake) pay(ctx context.Context, id, email string) (string, error) { |
| now := f.now() |
| c := f.customers[s.customer] |
| if c == nil { |
| - if email == "" { |
| + // Checkout's own address wins, and a signed-in tester (Checkout |
| + // was given their subject) is not asked for one: the subject says |
| + // who they are. Otherwise it is the address typed on the page, and |
| + // not one made up. |
| + if s.customerEmail != "" { |
| email = s.customerEmail |
| - } |
| - if email == "" { |
| - email = f.next("buyer") + "@example.com" |
| + } else if s.ClientReferenceID != "" { |
| + email = "" |
| + } else if !validEmail(email) { |
| + f.mu.Unlock() |
| + return "", errEmail |
| } |
| c = &customer{id: s.customer, email: email} |
| if c.id == "" { |
| @@ -698,6 +706,7 @@ func (f *Fake) cancel(ctx context.Context, customer, subID string) error { |
| var ( |
| errUnknown = errors.New("fakestripe: no such session or subscription") |
| errGone = errors.New("fakestripe: the session is over") |
| + errEmail = errors.New("fakestripe: the sandbox page needs an email address") |
| ) |
| // usable is why session s cannot be paid or declined, or nil. Hold f.mu. |
diff --git a/internal/fakestripe/fake_test.go b/internal/fakestripe/fake_test.go| index c065576..03fbda7 100644 |
| --- a/internal/fakestripe/fake_test.go |
| +++ b/internal/fakestripe/fake_test.go |
| @@ -603,3 +603,122 @@ func TestFakeCancelAfterAFailedRenewalEnds(t *testing.T) { |
| t.Errorf("events = %v, want %v", got, want) |
| } |
| } |
| + |
| +// anonymousCheckout starts a Checkout for someone who is not signed in: |
| +// no customer, no email, no subject. |
| +func (r *rig) anonymousCheckout(t *testing.T) stripeapi.CheckoutSession { |
| + t.Helper() |
| + cs, err := r.client.CreateCheckout(context.Background(), url.Values{ |
| + "mode": {"subscription"}, |
| + "line_items[0][price]": {"price_A"}, |
| + "line_items[0][quantity]": {"1"}, |
| + "success_url": {"https://site.example/done?session_id={CHECKOUT_SESSION_ID}"}, |
| + "cancel_url": {"https://site.example/join"}, |
| + }) |
| + if err != nil { |
| + t.Fatalf("CreateCheckout: %v", err) |
| + } |
| + return cs |
| +} |
| + |
| +// postPage posts form and returns the status, the Location and the body. |
| +func postPage(t *testing.T, u string, form url.Values) (int, string, string) { |
| + t.Helper() |
| + resp, err := noRedirects.PostForm(u, form) |
| + if err != nil { |
| + t.Fatalf("POST %s: %v", u, err) |
| + } |
| + defer resp.Body.Close() |
| + body, _ := io.ReadAll(resp.Body) |
| + return resp.StatusCode, resp.Header.Get("Location"), string(body) |
| +} |
| + |
| +// A tester who is not signed in types the address Checkout would have |
| +// asked for; the sandbox does not make one up. Someone signed in already |
| +// has theirs, and is not asked. |
| +func TestFakeCheckoutAsksAnAnonymousTesterForAnEmail(t *testing.T) { |
| + r := start(t) |
| + ctx := context.Background() |
| + |
| + known := r.checkout(t) |
| + _, page := get(t, known.URL) |
| + if strings.Contains(page, `name="email"`) { |
| + t.Errorf("a signed-in tester is asked for an email:\n%s", page) |
| + } |
| + // Their address is Checkout's, whatever the form carries. |
| + if code, _, _ := postPage(t, known.URL+"/pay", url.Values{"email": {"someone.else@example.com"}}); code != http.StatusSeeOther { |
| + t.Fatalf("Pay as a signed-in tester: status %d", code) |
| + } |
| + if got := r.sub(t, "sub_1").Customer.Email; got != "ana@example.com" { |
| + t.Errorf("a signed-in tester's customer email = %q, want Checkout's own", got) |
| + } |
| + |
| + // Nor is someone whose subject Checkout has, with no address: the |
| + // subject says who they are. |
| + subjectOnly, err := r.client.CreateCheckout(ctx, url.Values{ |
| + "mode": {"subscription"}, "line_items[0][price]": {"price_A"}, "line_items[0][quantity]": {"1"}, |
| + "success_url": {"https://site.example/done"}, "client_reference_id": {"bo"}, |
| + }) |
| + if err != nil { |
| + t.Fatalf("CreateCheckout: %v", err) |
| + } |
| + if _, page := get(t, subjectOnly.URL); strings.Contains(page, `name="email"`) { |
| + t.Errorf("a tester with a subject is asked for an email:\n%s", page) |
| + } |
| + if code, _, body := postPage(t, subjectOnly.URL+"/pay", nil); code != http.StatusSeeOther { |
| + t.Fatalf("Pay with a subject and no address: status %d\n%s", code, body) |
| + } |
| + if got := r.sub(t, "sub_2").Customer.Email; got != "" { |
| + t.Errorf("a subject-only customer's email = %q, want none", got) |
| + } |
| + r.hook.take() |
| + |
| + cs := r.anonymousCheckout(t) |
| + code, page := get(t, cs.URL) |
| + if code != http.StatusOK { |
| + t.Fatalf("checkout page: status %d", code) |
| + } |
| + keysOnly(t, page, "abono.sandbox.pay", "abono.sandbox.email") |
| + for _, want := range []string{`name="email"`, `type="email"`, "required", `autocomplete="email"`} { |
| + if !strings.Contains(page, want) { |
| + t.Errorf("the email field lacks %s:\n%s", want, page) |
| + } |
| + } |
| + if !regexp.MustCompile(`<label[^>]* for="([^"]+)"[^>]*>abono\.sandbox\.email</label>`).MatchString(page) { |
| + t.Errorf("the field has no label saying abono.sandbox.email:\n%s", page) |
| + } |
| + |
| + // Nothing typed, or something that is not an address: the page again, |
| + // 422, and nothing bought. |
| + for _, bad := range []url.Values{nil, {"email": {""}}, {"email": {" "}}, {"email": {"ana"}}, {"email": {"ana@"}}, |
| + {"email": {"Ana <ana@example.com>"}}, {"email": {"a b@example.com"}}} { |
| + code, loc, body := postPage(t, cs.URL+"/pay", bad) |
| + if code != http.StatusUnprocessableEntity || loc != "" { |
| + t.Errorf("Pay with %q: %d to %q, want 422 and no redirect", bad.Get("email"), code, loc) |
| + } |
| + keysOnly(t, body, "abono.sandbox.pay", "abono.sandbox.email") |
| + if !strings.Contains(body, `name="email"`) { |
| + t.Errorf("Pay with %q: the page is not the checkout again:\n%s", bad.Get("email"), body) |
| + } |
| + } |
| + if _, err := r.client.GetSubscription(ctx, "sub_3"); !stripeapi.NotFound(err) { |
| + t.Errorf("a rejected address bought a subscription: %v", err) |
| + } |
| + if got, _ := r.client.GetCheckout(ctx, cs.ID); got.Status != "open" { |
| + t.Errorf("after rejected addresses the session is %q, want open", got.Status) |
| + } |
| + if len(r.hook.take()) != 0 { |
| + t.Error("a rejected address sent events") |
| + } |
| + |
| + if code, _, body := postPage(t, cs.URL+"/pay", url.Values{"email": {" Bo@Example.com "}}); code != http.StatusSeeOther { |
| + t.Fatalf("Pay with an address: status %d\n%s", code, body) |
| + } |
| + sub := r.sub(t, "sub_3") |
| + if sub.Customer.Email != "Bo@Example.com" { |
| + t.Errorf("customer email = %q, want the address typed (trimmed)", sub.Customer.Email) |
| + } |
| + if done, _ := r.client.GetCheckout(ctx, cs.ID); done.CustomerDetails.Email != "Bo@Example.com" { |
| + t.Errorf("the finished session's email = %q", done.CustomerDetails.Email) |
| + } |
| +} |
diff --git a/internal/fakestripe/pages.go b/internal/fakestripe/pages.go| index d7d4a2a..518b7e9 100644 |
| --- a/internal/fakestripe/pages.go |
| +++ b/internal/fakestripe/pages.go |
| @@ -5,6 +5,7 @@ import ( |
| "fmt" |
| "html/template" |
| "net/http" |
| + "net/mail" |
| "net/url" |
| "strconv" |
| "strings" |
| @@ -36,6 +37,9 @@ var page = template.Must(template.New("page").Parse(`<!doctype html> |
| {{- with .Checkout}} |
| <p>{{.Item}}</p> |
| <form rst-form method="post" action="{{.PayAction}}"> |
| +{{- if .AskEmail}} |
| +<div rst-field><label rst-field-label for="sandbox-email">{{.EmailLabel}}</label><input rst-input id="sandbox-email" name="email" type="email" value="{{.Email}}" autocomplete="email" required{{if .EmailBad}} aria-invalid="true"{{end}}></div> |
| +{{- end}} |
| <button rst-btn="primary lg" type="submit">{{.Pay}}</button> |
| </form> |
| <form rst-form method="post" action="{{.DeclineAction}}"> |
| @@ -75,6 +79,12 @@ type checkoutData struct { |
| Item, Pay, Decline, Leave string |
| PayAction, DeclineAction string |
| LeaveHref template.URL |
| + // AskEmail is set for a tester who is not signed in: Checkout was |
| + // given no customer, address or subject, so the page asks for one. |
| + // Email is what they typed, and EmailBad that it was refused. |
| + AskEmail bool |
| + EmailLabel, Email string |
| + EmailBad bool |
| } |
| type portalItem struct { |
| @@ -85,7 +95,12 @@ func (f *Fake) pages(mux *http.ServeMux) { |
| mux.HandleFunc("GET /checkout/{id}", f.checkoutPage) |
| mux.HandleFunc("POST /checkout/{id}/pay", func(w http.ResponseWriter, r *http.Request) { |
| _ = r.ParseForm() |
| - back, err := f.pay(r.Context(), r.PathValue("id"), strings.TrimSpace(r.PostForm.Get("email"))) |
| + typed := strings.TrimSpace(r.PostForm.Get("email")) |
| + back, err := f.pay(r.Context(), r.PathValue("id"), typed) |
| + if errors.Is(err, errEmail) { |
| + f.showCheckout(w, r.PathValue("id"), http.StatusUnprocessableEntity, typed) |
| + return |
| + } |
| f.redirect(w, r, back, err) |
| }) |
| mux.HandleFunc("POST /checkout/{id}/decline", func(w http.ResponseWriter, r *http.Request) { |
| @@ -129,7 +144,13 @@ func (f *Fake) checkoutPage(w http.ResponseWriter, r *http.Request) { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| - id := r.PathValue("id") |
| + f.showCheckout(w, r.PathValue("id"), http.StatusOK, "") |
| +} |
| + |
| +// showCheckout renders session id's page with status. typed is the |
| +// address the tester sent that the page refused (status 422), put back in |
| +// the field; "" otherwise. |
| +func (f *Fake) showCheckout(w http.ResponseWriter, id string, status int, typed string) { |
| f.mu.Lock() |
| s := f.sessions[id] |
| if s == nil || s.Status != "open" { |
| @@ -137,6 +158,7 @@ func (f *Fake) checkoutPage(w http.ResponseWriter, r *http.Request) { |
| f.expired(w, s == nil) |
| return |
| } |
| + ask := f.customers[s.customer] == nil && s.customerEmail == "" && s.ClientReferenceID == "" |
| plan, amount := "", "" |
| if p := f.price(s.price); p != nil { |
| plan, amount = p.plan, f.priceLine(p) |
| @@ -150,15 +172,26 @@ func (f *Fake) checkoutPage(w http.ResponseWriter, r *http.Request) { |
| Decline: f.t("abono.sandbox.decline"), |
| PayAction: f.action("checkout", id, "pay"), DeclineAction: f.action("checkout", id, "decline"), |
| } |
| + if ask { |
| + c.AskEmail, c.EmailLabel = true, f.t("abono.sandbox.email") |
| + c.Email, c.EmailBad = typed, status == http.StatusUnprocessableEntity |
| + } |
| if cancelURL != "" { |
| c.Leave, c.LeaveHref = f.t("abono.sandbox.leave"), safeURL(cancelURL) |
| } |
| - f.render(w, http.StatusOK, pageData{ |
| + f.render(w, status, pageData{ |
| Title: f.t("abono.sandbox.checkout_title"), Banner: f.t("abono.sandbox.banner"), |
| Intro: f.t("abono.sandbox.checkout_intro"), Checkout: c, |
| }) |
| } |
| +// validEmail is whether s is one plain address, "ana@example.com", not |
| +// "Ana <ana@example.com>" and not a list. |
| +func validEmail(s string) bool { |
| + a, err := mail.ParseAddress(s) |
| + return err == nil && a.Address == s |
| +} |
| + |
| // priceLine is the price as abono.price.per_<interval> says it, or the |
| // bare amount for a one-off. Hold f.mu. |
| func (f *Fake) priceLine(p *price) string { |
diff --git a/locales/en.toml b/locales/en.toml| index aebaa9c..347aa46 100644 |
| --- a/locales/en.toml |
| +++ b/locales/en.toml |
| @@ -40,12 +40,15 @@ abono.button.end_free = "End" |
| abono.button.end_free_confirm = "End free membership" |
| abono.button.keep_free = "Keep it" |
| abono.button.adopt = "Add to plan" |
| +abono.button.make_sold = "Sell this price" |
| +abono.button.add_price = "Add price" |
| # The sandbox's stand-in Stripe pages. |
| abono.sandbox.banner = "Sandbox. No money moves on this page, and no card is asked for. It stands in for Stripe while the site is being built." |
| abono.sandbox.checkout_title = "Sandbox checkout" |
| abono.sandbox.checkout_intro = "This is a stand-in for Stripe's payment page, for development only. Nothing is charged. Choose what should happen." |
| abono.sandbox.checkout_item = "{plan}: {price}" |
| +abono.sandbox.email = "Email address" |
| abono.sandbox.pay = "Pretend to pay" |
| abono.sandbox.decline = "Pretend the payment fails" |
| abono.sandbox.leave = "Go back without paying" |
| @@ -66,6 +69,7 @@ abono.pages.plan_back = "All plans" |
| abono.pages.plan_signed_out = "You'll enter your email on the next page." |
| abono.pages.done_title = "Thank you" |
| abono.pages.done_paid = "Your payment went through and you're a member. It can take a moment to show up. If it isn't there yet, refresh in a minute." |
| +abono.pages.done_processing = "Your payment is still going through. Your membership starts as soon as it does, so there's no need to pay again." |
| abono.pages.done_signin = "Sign in with {email} to read as a member." |
| abono.pages.done_unpaid_title = "No payment was made" |
| abono.pages.done_unpaid_body = "You haven't been charged. You can try again whenever you like." |
| @@ -96,6 +100,7 @@ abono.owner.free_note = "Note" |
| abono.owner.free_ends_hint = "Leave empty for no end date." |
| abono.owner.free_note_hint = "Only you can see this." |
| abono.owner.free_given = "Free membership given." |
| +abono.owner.free_no_member = "Nobody has an account with that email address yet." |
| abono.owner.end_title = "End this free membership?" |
| abono.owner.end_body = "{member} will lose access to {plan} straight away. You can give it to them again later." |
| abono.owner.end_done = "Free membership ended." |
| @@ -104,6 +109,9 @@ abono.owner.offers_intro = "What you sell. The prices come from your Stripe acco |
| abono.owner.offers_empty = "No plans yet. Add them in the site's settings, then restart." |
| abono.owner.price_sold = "On sale" |
| abono.owner.price_legacy = "Old price, still counts" |
| +abono.owner.price_unsupported = "Not offered to readers. Only prices that bill once a month or once a year can be sold here." |
| +abono.owner.add_price_title = "Add a price from Stripe" |
| +abono.owner.add_price_id = "Stripe price ID" |
| abono.owner.unrecognised_title = "Memberships we don't recognise" |
| abono.owner.unrecognised_body = "These are paying on a Stripe price that isn't part of any of your plans, so they get no access. Choose a plan for each one." |
| abono.owner.unrecognised_warn = "This adds the price to the plan, so everyone paying that price joins it." |
| @@ -131,3 +139,5 @@ abono.error.not_available = "Payments aren't available right now. Please try aga |
| abono.error.free_plan = "Choose one of your plans." |
| abono.error.sync_failed = "We couldn't reach Stripe, so nothing was changed. Try again in a moment." |
| abono.error.adopt_failed = "Stripe doesn't have that subscription. It may have been deleted." |
| +abono.error.add_price_failed = "That price couldn't be added. Check the ID in Stripe and try again." |
| +abono.error.sync_partial = "Some memberships couldn't be checked with Stripe. The rest are up to date. Try again in a moment." |
diff --git a/sandbox_test.go b/sandbox_test.go| index 518b66c..c0cb263 100644 |
| --- a/sandbox_test.go |
| +++ b/sandbox_test.go |
| @@ -123,7 +123,7 @@ func TestSandboxRunsTheSameFlowWithoutStripe(t *testing.T) { |
| } |
| // The approved English, not the keys (final review I8, ruling R40). |
| approved := abono.Catalog() |
| - for _, key := range []string{"abono.sandbox.banner", "abono.sandbox.checkout_title", "abono.sandbox.pay", "abono.sandbox.decline"} { |
| + for _, key := range []string{"abono.sandbox.banner", "abono.sandbox.checkout_title", "abono.sandbox.pay", "abono.sandbox.decline", "abono.sandbox.email"} { |
| if text := html.EscapeString(approved[key]); approved[key] == "" || !strings.Contains(page, text) { |
| t.Errorf("the checkout page does not say %s (%q)", key, approved[key]) |
| } |
| @@ -138,6 +138,25 @@ func TestSandboxRunsTheSameFlowWithoutStripe(t *testing.T) { |
| t.Errorf("a cross-site Pay: status %d, want 403", code) |
| } |
| + // Signed out, the tester types the address Checkout lacks. Nothing, or |
| + // something that is not an address, gets the page back (422) and buys |
| + // nothing. |
| + if !strings.Contains(page, `type="email"`) || !strings.Contains(page, `name="email"`) { |
| + t.Errorf("the signed-out checkout page has no email field:\n%s", page) |
| + } |
| + for _, bad := range []string{"", "ana", "ana@", "Ana <ana@example.com>"} { |
| + code, loc, again := b.do(http.MethodPost, u+"/pay", url.Values{"email": {bad}}) |
| + if code != http.StatusUnprocessableEntity || loc != "" || !strings.Contains(again, `name="email"`) { |
| + t.Errorf("Pay with %q: status %d to %q, want the page again with 422", bad, code, loc) |
| + } |
| + } |
| + if hasPlan(t, s, "ana", "annual") { |
| + t.Fatal("a refused address bought a membership") |
| + } |
| + if asked, changes := rec.take(); len(asked) != 0 || len(changes) != 0 { |
| + t.Fatalf("a refused address reached the app: Holder %v, OnChange %v", asked, changes) |
| + } |
| + |
| code, done, _ := b.do(http.MethodPost, u+"/pay", url.Values{"email": {"ana@example.com"}}) |
| if code != http.StatusSeeOther || !strings.HasPrefix(done, origin+"/done?session_id=") { |
| t.Fatalf("Pay: %d to %q", code, done) |
| @@ -196,6 +215,45 @@ func TestSandboxRunsTheSameFlowWithoutStripe(t *testing.T) { |
| wantStatus(t, deliverWith(s, body, sign("whsec_any", body, clk.Now())), http.StatusBadRequest) |
| } |
| +// A signed-in tester gives Checkout their address, so the page does not |
| +// ask for one, and what a form sends does not replace it. |
| +func TestSandboxDoesNotAskASignedInTesterForAnEmail(t *testing.T) { |
| + ctx := context.Background() |
| + d := openDB(t) |
| + if _, err := migrate.Apply(ctx, d, migrate.Merge(sessions.Schema, abono.Schema)); err != nil { |
| + t.Fatalf("migrate.Apply: %v", err) |
| + } |
| + const origin = "http://localhost:8080" |
| + rec := &recorder{subject: "ana"} |
| + s, err := abono.New(abono.Config{DB: d.G, Origin: origin, Sandbox: true, Holder: rec.Holder, OnChange: rec.OnChange, |
| + Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))}) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_annual"}}); err != nil { |
| + t.Fatalf("SeedPlans: %v", err) |
| + } |
| + u, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "annual", Email: "ana@example.com", SuccessPath: "/done"}) |
| + if err != nil { |
| + t.Fatalf("Checkout: %v", err) |
| + } |
| + w := httptest.NewRecorder() |
| + s.SandboxHandler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, strings.TrimPrefix(u, origin), nil)) |
| + if w.Code != http.StatusOK || strings.Contains(w.Body.String(), `name="email"`) { |
| + t.Errorf("the page for someone with an address: status %d\n%s", w.Code, w.Body) |
| + } |
| + r := httptest.NewRequest(http.MethodPost, strings.TrimPrefix(u, origin)+"/pay", strings.NewReader(url.Values{"email": {"other@example.com"}}.Encode())) |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + w = httptest.NewRecorder() |
| + s.SandboxHandler().ServeHTTP(w, r) |
| + if w.Code != http.StatusSeeOther { |
| + t.Fatalf("Pay: status %d", w.Code) |
| + } |
| + if asked, _ := rec.take(); len(asked) != 1 || asked[0] != "ana@example.com" { |
| + t.Errorf("Holder asked %v, want Checkout's own address", asked) |
| + } |
| +} |
| + |
| func TestSandboxHandlerIsOffWithoutTheSandbox(t *testing.T) { |
| now := t0 |
| s, _ := newService(t, &now, abono.Config{}) |
diff --git a/templates_test.go b/templates_test.go| index 6f38a89..5ba0eda 100644 |
| --- a/templates_test.go |
| +++ b/templates_test.go |
| @@ -701,46 +701,16 @@ func TestUsedKeysSeesEveryConsumer(t *testing.T) { |
| } |
| } |
| -// pendingReview is the keys a consumer uses whose English is still in copy |
| -// review, so en.toml has no text for them yet. The gate tolerates exactly |
| -// these: each must be used and still absent, so the list is emptied as the |
| -// reviewed text lands (Task 8b's follow-up), and a key left on it after |
| -// that fails. |
| -var pendingReview = []string{ |
| - "abono.owner.free_no_member", |
| - // The final-review fix wave's keys, for the third mini copy review. |
| - "abono.pages.done_processing", |
| - "abono.owner.price_unsupported", |
| - "abono.owner.add_price_title", |
| - "abono.owner.add_price_id", |
| - "abono.button.add_price", |
| - // Beyond the brief's list: the put-on-sale button and the add-price |
| - // form's failure needed words of their own. |
| - "abono.button.make_sold", |
| - "abono.error.add_price_failed", |
| - "abono.error.sync_partial", |
| -} |
| - |
| -// Every key a consumer uses is in en.toml (bar the pendingReview ones), and |
| -// every key in en.toml is used by one of them. |
| +// 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") |
| + t.Fatal("locales/en.toml has no messages") |
| } |
| used := usedKeys(t) |
| - pending := map[string]bool{} |
| - for _, k := range pendingReview { |
| - pending[k] = true |
| - if !used[k] { |
| - t.Errorf("%s is in pendingReview but nothing uses it", k) |
| - } |
| - if _, ok := cat[k]; ok { |
| - t.Errorf("%s is in pendingReview but locales/en.toml now has it: remove it from the list", k) |
| - } |
| - } |
| for k := range used { |
| - if _, ok := cat[k]; !ok && !pending[k] { |
| + if _, ok := cat[k]; !ok { |
| t.Errorf("%s is used but not in locales/en.toml", k) |
| } |
| } |
| @@ -752,9 +722,6 @@ func TestCatalogKeysAreUsedAndDefined(t *testing.T) { |
| } |
| func TestNoEmDashesInCopy(t *testing.T) { |
| - if len(abono.Catalog()) == 0 { |
| - t.Skip("Task 8b: approved copy pending") |
| - } |
| src, err := fs.ReadFile(osDir("locales"), "en.toml") |
| if err != nil { |
| t.Fatal(err) |