package fakestripe_test import ( "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "net/url" "reflect" "regexp" "strings" "sync" "testing" "time" "amadan.net/rastrillo/abono/internal/fakestripe" "amadan.net/rastrillo/abono/internal/stripeapi" ) // t0 is the fake's clock before any Advance. var t0 = time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC) const secret = "whsec_fake" // delivery is one event the fake POSTed. type delivery struct { signature string body []byte } // hook is a webhook endpoint that keeps what it is sent. type hook struct { mu sync.Mutex got []delivery code int // what it answers; 0 is 200 } func (h *hook) ServeHTTP(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) h.mu.Lock() defer h.mu.Unlock() h.got = append(h.got, delivery{r.Header.Get("Stripe-Signature"), body}) if h.code != 0 { w.WriteHeader(h.code) } } // take returns what arrived since the last take. func (h *hook) take() []delivery { h.mu.Lock() defer h.mu.Unlock() got := h.got h.got = nil return got } // types is each delivery's event type, checking every signature on the // way. func types(t *testing.T, ds []delivery, now time.Time) []string { t.Helper() var out []string for _, d := range ds { if err := stripeapi.VerifySignature(d.signature, d.body, []string{secret}, now); err != nil { t.Errorf("event %s: signature does not verify: %v", d.body, err) } if err := stripeapi.VerifySignature(d.signature, d.body, []string{"whsec_other"}, now); err == nil { t.Errorf("event %s verifies with another secret", d.body) } var e struct { Type string `json:"type"` } if err := json.Unmarshal(d.body, &e); err != nil || e.Type == "" { t.Fatalf("not an event: %s", d.body) } out = append(out, e.Type) } return out } type rig struct { f *fakestripe.Fake base string client *stripeapi.Client hook *hook } // start serves a fake on 127.0.0.1 whose pages are at its own address, // sending its events to a hook. func start(t *testing.T) *rig { t.Helper() h := &hook{} hooks := httptest.NewServer(h) t.Cleanup(hooks.Close) srv := httptest.NewUnstartedServer(nil) base := "http://" + srv.Listener.Addr().String() f := fakestripe.New(fakestripe.Options{ WebhookURL: hooks.URL, WebhookSecret: secret, PageBase: base, Now: func() time.Time { return t0 }, }) srv.Config.Handler = f.Handler() srv.Start() t.Cleanup(srv.Close) f.AddPrice("price_A", "annual", 6000, "eur", "year") return &rig{f: f, base: base, client: &stripeapi.Client{Key: "rk_test_fake", Base: base}, hook: h} } // noRedirects is a client that reports a redirect rather than following // it. var noRedirects = &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} func get(t *testing.T, u string) (int, string) { t.Helper() resp, err := noRedirects.Get(u) if err != nil { t.Fatalf("GET %s: %v", u, err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) return resp.StatusCode, string(body) } func post(t *testing.T, u string, form url.Values) (int, string) { t.Helper() resp, err := noRedirects.PostForm(u, form) if err != nil { t.Fatalf("POST %s: %v", u, err) } defer resp.Body.Close() _, _ = io.Copy(io.Discard, resp.Body) return resp.StatusCode, resp.Header.Get("Location") } // keysOnly checks that every piece of text on a page is a message key: // the pages carry no English of their own (ruling R20). func keysOnly(t *testing.T, page string, want ...string) { t.Helper() for _, m := range regexp.MustCompile(`>([^<]+)<`).FindAllStringSubmatch(page, -1) { if s := strings.TrimSpace(m[1]); s != "" && !strings.HasPrefix(s, "abono.") { t.Errorf("the page says %q, which is not a message key", s) } } for _, key := range want { if !strings.Contains(page, ">"+key+"<") { t.Errorf("the page does not show %s:\n%s", key, page) } } } // checkout starts a Checkout for ana on price_A, as abono does. func (r *rig) checkout(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"}, "client_reference_id": {"ana"}, "customer_email": {"ana@example.com"}, "subscription_data[metadata][abono_subject]": {"ana"}, }) if err != nil { t.Fatalf("CreateCheckout: %v", err) } return cs } // subscribe checks out and pays, and returns the subscription's id. func (r *rig) subscribe(t *testing.T) string { t.Helper() cs := r.checkout(t) if code, _ := post(t, cs.URL+"/pay", nil); code != http.StatusSeeOther { t.Fatalf("Pay: status %d", code) } got, err := r.client.GetCheckout(context.Background(), cs.ID) if err != nil { t.Fatalf("GetCheckout: %v", err) } r.hook.take() return got.Subscription } func (r *rig) sub(t *testing.T, id string) stripeapi.Subscription { t.Helper() s, err := r.client.GetSubscription(context.Background(), id) if err != nil { t.Fatalf("GetSubscription(%s): %v", id, err) } return s } func TestFakeCheckoutPayCreatesAnActiveSubscriptionAndSignsAnEvent(t *testing.T) { r := start(t) ctx := context.Background() cs := r.checkout(t) if cs.ID != "cs_1" || cs.URL != r.base+"/checkout/cs_1" || cs.Status != "open" || cs.PaymentStatus != "unpaid" { t.Fatalf("session = %+v", cs) } code, page := get(t, cs.URL) if code != http.StatusOK { t.Fatalf("checkout page: status %d", code) } keysOnly(t, page, "abono.sandbox.banner", "abono.sandbox.checkout_title", "abono.sandbox.checkout_intro", "abono.sandbox.checkout_item", "abono.sandbox.pay", "abono.sandbox.decline", "abono.sandbox.leave") if !strings.Contains(page, `href="https://site.example/join"`) { t.Errorf("the page does not lead back to the cancel URL:\n%s", page) } if len(r.hook.take()) != 0 { t.Fatal("an event was sent before paying") } code, loc := post(t, cs.URL+"/pay", nil) if code != http.StatusSeeOther || loc != "https://site.example/done?session_id=cs_1" { t.Fatalf("Pay: %d to %q, want 303 to the success URL with the session id", code, loc) } sub := r.sub(t, "sub_1") price, interval := sub.PriceID() if sub.Status != "active" || sub.CancelAtPeriodEnd || sub.Customer.ID != "cus_1" || sub.Customer.Email != "ana@example.com" || price != "price_A" || interval != "year" || sub.Metadata["abono_subject"] != "ana" { t.Errorf("subscription = %+v", sub) } // 2025-03-31.basil: the period end is on the items, not the subscription. if !sub.PeriodStart().Equal(t0) { t.Errorf("period start = %v, want %v", sub.PeriodStart(), t0) } if sub.CurrentPeriodEnd != 0 || !sub.PeriodEnd().Equal(t0.AddDate(1, 0, 0)) { t.Errorf("period end: top level %d, items %v; want only the items, a year on", sub.CurrentPeriodEnd, sub.PeriodEnd()) } done, err := r.client.GetCheckout(ctx, "cs_1") if err != nil { t.Fatalf("GetCheckout: %v", err) } if done.Status != "complete" || done.PaymentStatus != "paid" || done.Subscription != "sub_1" || done.ClientReferenceID != "ana" || done.CustomerDetails.Email != "ana@example.com" { t.Errorf("finished session = %+v", done) } if got, want := types(t, r.hook.take(), t0), []string{"customer.subscription.created", "checkout.session.completed"}; !reflect.DeepEqual(got, want) { t.Errorf("events = %v, want %v", got, want) } // Paid is paid: paying again (a resubmitted form, or a retry after a // failed delivery) goes to the success page again and buys nothing, // and the page and its buttons are gone. if code, loc := post(t, cs.URL+"/pay", nil); code != http.StatusSeeOther || loc != "https://site.example/done?session_id=cs_1" { t.Errorf("paying twice: %d to %q, want 303 to the success URL", code, loc) } if _, err := r.client.GetSubscription(ctx, "sub_2"); !stripeapi.NotFound(err) { t.Errorf("paying twice made a second subscription: %v", err) } code, page = get(t, cs.URL) if code != http.StatusGone { t.Errorf("a paid session's page: status %d, want 410", code) } keysOnly(t, page, "abono.sandbox.banner", "abono.sandbox.expired") if len(r.hook.take()) != 0 { t.Error("paying twice sent events") } } func TestFakeCheckoutDeclineMakesNothing(t *testing.T) { r := start(t) cs := r.checkout(t) code, loc := post(t, cs.URL+"/decline", nil) if code != http.StatusSeeOther || loc != "https://site.example/join" { t.Fatalf("Decline: %d to %q, want 303 to the cancel URL", code, loc) } got, err := r.client.GetCheckout(context.Background(), cs.ID) if err != nil { t.Fatalf("GetCheckout: %v", err) } if got.Status != "expired" || got.Subscription != "" { t.Errorf("declined session = %+v", got) } if _, err := r.client.GetSubscription(context.Background(), "sub_1"); !stripeapi.NotFound(err) { t.Errorf("a subscription after declining: %v", err) } if got, want := types(t, r.hook.take(), t0), []string{"checkout.session.expired"}; !reflect.DeepEqual(got, want) { t.Errorf("events = %v, want %v", got, want) } if code, _ := get(t, r.base+"/checkout/cs_404"); code != http.StatusNotFound { t.Errorf("an unknown session's page: status %d, want 404", code) } } func TestFakeRenewalFailureGoesPastDue(t *testing.T) { r := start(t) id := r.subscribe(t) // A renewal that is paid moves the period on. r.f.Advance(366 * 24 * time.Hour) now := t0.Add(366 * 24 * time.Hour) sub := r.sub(t, id) if sub.Status != "active" || !sub.PeriodEnd().Equal(t0.AddDate(2, 0, 0)) { t.Errorf("after a paid renewal: %s until %v", sub.Status, sub.PeriodEnd()) } if got, want := types(t, r.hook.take(), now), []string{"invoice.paid", "customer.subscription.updated"}; !reflect.DeepEqual(got, want) { t.Errorf("renewal events = %v, want %v", got, want) } // The next one fails: past_due, and like Stripe the period still // moves on, to the one the failed invoice was for. r.f.FailNextRenewal(id) r.f.Advance(366 * 24 * time.Hour) now = now.Add(366 * 24 * time.Hour) sub = r.sub(t, id) if sub.Status != "past_due" || !sub.PeriodEnd().Equal(t0.AddDate(3, 0, 0)) { t.Errorf("after a failed renewal: %s until %v, want past_due until %v", sub.Status, sub.PeriodEnd(), t0.AddDate(3, 0, 0)) } // The unpaid period starts where the paid one ended: abono counts its // 30 days from here (ruling R21). if !sub.PeriodStart().Equal(t0.AddDate(2, 0, 0)) { t.Errorf("after a failed renewal the period starts %v, want %v", sub.PeriodStart(), t0.AddDate(2, 0, 0)) } if got, want := types(t, r.hook.take(), now), []string{"invoice.payment_failed", "customer.subscription.updated"}; !reflect.DeepEqual(got, want) { t.Errorf("failure events = %v, want %v", got, want) } // Stripe keeps invoicing a past_due subscription (an account may leave // it past_due for good): the next renewal fails too, and the period // moves on again. abono must not count its 30 days from this new start // (ruling R32). r.f.Advance(366 * 24 * time.Hour) now = now.Add(366 * 24 * time.Hour) sub = r.sub(t, id) if sub.Status != "past_due" || !sub.PeriodStart().Equal(t0.AddDate(3, 0, 0)) || !sub.PeriodEnd().Equal(t0.AddDate(4, 0, 0)) { t.Errorf("a period later: %s from %v until %v, want past_due from %v until %v", sub.Status, sub.PeriodStart(), sub.PeriodEnd(), t0.AddDate(3, 0, 0), t0.AddDate(4, 0, 0)) } if got, want := types(t, r.hook.take(), now), []string{"invoice.payment_failed", "customer.subscription.updated"}; !reflect.DeepEqual(got, want) { t.Errorf("events of the second failed renewal = %v, want %v", got, want) } } // TestFakeRenewsOnTheBillingAnchor is Stripe's month-end rule: a monthly // subscription anchored on the 31st renews on the last day of a shorter // month, then on the 31st again, never drifting to the 3rd. func TestFakeRenewsOnTheBillingAnchor(t *testing.T) { jan31 := time.Date(2027, 1, 31, 12, 0, 0, 0, time.UTC) f := fakestripe.New(fakestripe.Options{Now: func() time.Time { return jan31 }}) f.AddPrice("price_M", "monthly", 600, "eur", "month") id := f.PutSubscription(fakestripe.Subscription{Status: "active", Price: "price_M", PeriodStart: time.Date(2026, 12, 31, 12, 0, 0, 0, time.UTC), PeriodEnd: jan31}) period := func() (time.Time, time.Time) { t.Helper() b, ok := f.SubscriptionJSON(id) if !ok { t.Fatal("the subscription is gone") } var s stripeapi.Subscription if err := json.Unmarshal(b, &s); err != nil { t.Fatal(err) } return s.PeriodStart(), s.PeriodEnd() } f.Tick() feb28 := time.Date(2027, 2, 28, 12, 0, 0, 0, time.UTC) if start, end := period(); !start.Equal(jan31) || !end.Equal(feb28) { t.Errorf("renewed on 31 January: %v to %v, want %v to %v", start, end, jan31, feb28) } f.Advance(feb28.Sub(jan31)) mar31 := time.Date(2027, 3, 31, 12, 0, 0, 0, time.UTC) if start, end := period(); !start.Equal(feb28) || !end.Equal(mar31) { t.Errorf("renewed on 28 February: %v to %v, want %v to %v", start, end, feb28, mar31) } } func TestFakePortalCancelSetsCancelAtPeriodEnd(t *testing.T) { r := start(t) ctx := context.Background() id := r.subscribe(t) u, err := r.client.PortalURL(ctx, "cus_1", "https://site.example/account") if err != nil { t.Fatalf("PortalURL: %v", err) } if u != r.base+"/portal/cus_1" { t.Fatalf("portal URL = %q", u) } code, page := get(t, u) if code != http.StatusOK { t.Fatalf("portal page: status %d", code) } keysOnly(t, page, "abono.sandbox.banner", "abono.sandbox.portal_title", "abono.sandbox.portal_intro", "abono.sandbox.portal_plan", "abono.sandbox.portal_cancel", "abono.sandbox.portal_return") if !strings.Contains(page, `href="https://site.example/account"`) || !strings.Contains(page, `value="`+id+`"`) { t.Errorf("the page lacks the return link or the subscription:\n%s", page) } code, loc := post(t, u+"/cancel", url.Values{"subscription": {id}}) if code != http.StatusSeeOther || loc != u { t.Fatalf("Cancel: %d to %q, want 303 back to the portal", code, loc) } if sub := r.sub(t, id); sub.Status != "active" || !sub.CancelAtPeriodEnd { t.Errorf("after cancelling: %s, cancel_at_period_end %v", sub.Status, sub.CancelAtPeriodEnd) } if got, want := types(t, r.hook.take(), t0), []string{"customer.subscription.updated"}; !reflect.DeepEqual(got, want) { t.Errorf("events = %v, want %v", got, want) } if _, page = get(t, u); strings.Contains(page, "abono.sandbox.portal_cancel") { t.Error("a cancelling subscription can still be cancelled") } // Somebody else's subscription is not this customer's to cancel. if code, _ := post(t, r.base+"/portal/cus_2/cancel", url.Values{"subscription": {id}}); code != http.StatusNotFound { t.Errorf("cancelling another customer's subscription: status %d, want 404", code) } // At the period end it ends. r.f.Advance(366 * 24 * time.Hour) if sub := r.sub(t, id); sub.Status != "canceled" { t.Errorf("past the period end: %s, want canceled", sub.Status) } if got, want := types(t, r.hook.take(), t0.Add(366*24*time.Hour)), []string{"customer.subscription.deleted"}; !reflect.DeepEqual(got, want) { t.Errorf("events = %v, want %v", got, want) } } func TestFakeAPIIsStripes(t *testing.T) { r := start(t) ctx := context.Background() // A key and the pinned version are required, as they are of abono. req, _ := http.NewRequest(http.MethodGet, r.base+"/v1/prices/price_A", nil) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Errorf("no key: status %d, want 401", resp.StatusCode) } req.Header.Set("Authorization", "Bearer rk_test_fake") req.Header.Set("Stripe-Version", "2020-08-27") if resp, err = http.DefaultClient.Do(req); err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Errorf("another API version: status %d, want 400", resp.StatusCode) } p, err := r.client.GetPrice(ctx, "price_A") if err != nil || p.UnitAmount != 6000 || p.Currency != "eur" || p.Recurring.Interval != "year" { t.Errorf("GetPrice = %+v, %v", p, err) } if _, err := r.client.GetPrice(ctx, "price_nope"); !stripeapi.NotFound(err) { t.Errorf("an unknown price: %v, want not found", err) } r.f.AddPromotionCode("INNOVATE", "promo_1") if id, err := r.client.PromotionCode(ctx, "INNOVATE"); err != nil || id != "promo_1" { t.Errorf("PromotionCode = %q, %v", id, err) } if id, err := r.client.PromotionCode(ctx, "NOPE"); err != nil || id != "" { t.Errorf("an unknown code = %q, %v", id, err) } // Ids come from a counter, and the list holds every subscription. for range 3 { r.subscribe(t) } var ids []string if err := r.client.ListSubscriptions(ctx, func(s stripeapi.Subscription) error { ids = append(ids, s.ID+"/"+s.Customer.Email) return nil }); err != nil { t.Fatalf("ListSubscriptions: %v", err) } if want := []string{"sub_1/ana@example.com", "sub_2/ana@example.com", "sub_3/ana@example.com"}; !reflect.DeepEqual(ids, want) { t.Errorf("listed %v, want %v", ids, want) } } func TestFakeKeepsAFailedWebhookDeliveryForLater(t *testing.T) { r := start(t) r.hook.mu.Lock() r.hook.code = http.StatusInternalServerError r.hook.mu.Unlock() // Like Stripe, Checkout carries on when the webhook fails; the event // waits, and the rest wait behind it. cs := r.checkout(t) if code, _ := post(t, cs.URL+"/pay", nil); code != http.StatusSeeOther { t.Fatalf("Pay with a failing webhook: status %d, want 303", code) } if errs := r.f.DeliveryErrors(); len(errs) != 1 { t.Errorf("delivery errors = %v, want one", errs) } if got := len(r.hook.take()); got != 1 { t.Errorf("attempts = %d, want 1: the second event waits behind the first", got) } r.hook.mu.Lock() r.hook.code = 0 r.hook.mu.Unlock() r.f.Tick() if got, want := types(t, r.hook.take(), t0), []string{"customer.subscription.created", "checkout.session.completed"}; !reflect.DeepEqual(got, want) { t.Errorf("redelivered %v, want %v in order", got, want) } if errs := r.f.DeliveryErrors(); len(errs) != 0 { t.Errorf("delivery errors after redelivery = %v, want none", errs) } } // A sandbox's in-process delivery that fails makes the action fail, and // the event waits: the next action sends it first, without redoing the // action. func TestFakeRetriesAFailedInProcessDelivery(t *testing.T) { var mu sync.Mutex var got []string failNext := false deliver := func(_ context.Context, body []byte) error { var e struct { Type string `json:"type"` } _ = json.Unmarshal(body, &e) mu.Lock() defer mu.Unlock() if failNext { failNext = false return errors.New("the app's OnChange failed") } got = append(got, e.Type) return nil } srv := httptest.NewUnstartedServer(nil) base := "http://" + srv.Listener.Addr().String() f := fakestripe.New(fakestripe.Options{Deliver: deliver, PageBase: base, Now: func() time.Time { return t0 }}) srv.Config.Handler = f.Handler() srv.Start() t.Cleanup(srv.Close) f.AddPrice("price_A", "annual", 6000, "eur", "year") r := &rig{f: f, base: base, client: &stripeapi.Client{Key: "rk_test_fake", Base: base}, hook: &hook{}} id := r.subscribe(t) u, err := r.client.PortalURL(context.Background(), "cus_1", "https://site.example/account") if err != nil { t.Fatal(err) } mu.Lock() got, failNext = nil, true mu.Unlock() if code, _ := post(t, u+"/cancel", url.Values{"subscription": {id}}); code != http.StatusInternalServerError { t.Fatalf("Cancel whose delivery failed: status %d, want 500", code) } if len(f.DeliveryErrors()) != 1 { t.Errorf("delivery errors = %v, want one", f.DeliveryErrors()) } code, loc := post(t, u+"/cancel", url.Values{"subscription": {id}}) if code != http.StatusSeeOther || loc != u { t.Fatalf("Cancel again: %d to %q, want 303 back to the portal", code, loc) } mu.Lock() defer mu.Unlock() if want := []string{"customer.subscription.updated"}; !reflect.DeepEqual(got, want) { t.Errorf("delivered %v, want the one queued event once", got) } if sub := r.sub(t, id); sub.Status != "active" || !sub.CancelAtPeriodEnd { t.Errorf("subscription = %s, cancelling %v", sub.Status, sub.CancelAtPeriodEnd) } var ids []string _ = r.client.ListSubscriptions(context.Background(), func(s stripeapi.Subscription) error { ids = append(ids, s.ID); return nil }) if !reflect.DeepEqual(ids, []string{id}) { t.Errorf("subscriptions = %v, want only %s", ids, id) } } // A subscription cancelled while past_due ends at its period end too. func TestFakeCancelAfterAFailedRenewalEnds(t *testing.T) { r := start(t) id := r.subscribe(t) r.f.FailNextRenewal(id) r.f.Advance(366 * 24 * time.Hour) if sub := r.sub(t, id); sub.Status != "past_due" { t.Fatalf("after a failed renewal: %s", sub.Status) } r.hook.take() u, err := r.client.PortalURL(context.Background(), "cus_1", "https://site.example/account") if err != nil { t.Fatal(err) } if code, _ := post(t, u+"/cancel", url.Values{"subscription": {id}}); code != http.StatusSeeOther { t.Fatalf("Cancel: status %d", code) } if sub := r.sub(t, id); sub.Status != "past_due" || !sub.CancelAtPeriodEnd { t.Errorf("after cancelling: %s, cancelling %v", sub.Status, sub.CancelAtPeriodEnd) } if got, want := types(t, r.hook.take(), t0.Add(366*24*time.Hour)), []string{"customer.subscription.updated"}; !reflect.DeepEqual(got, want) { t.Errorf("events = %v, want %v", got, want) } r.f.Advance(366 * 24 * time.Hour) if sub := r.sub(t, id); sub.Status != "canceled" { t.Errorf("past the period end: %s, want canceled", sub.Status) } if got, want := types(t, r.hook.take(), t0.Add(2*366*24*time.Hour)), []string{"customer.subscription.deleted"}; !reflect.DeepEqual(got, want) { 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(`]* for="([^"]+)"[^>]*>abono\.sandbox\.email`).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 "}}, {"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) } }