// Package abonotest is a fake Stripe for an app's tests to run abono // against end to end: the API abono calls, Checkout and Customer Portal // pages that pay, fail or cancel on a POST, and signed webhook events, // all on an httptest server on 127.0.0.1. // // stripe := abonotest.New(t) // stripe.AddPrice("price_annual", "annual", 6000, "eur", "year") // svc, _ := abono.New(stripe.Config(abono.Config{DB: db, Origin: app.URL, ...})) // stripe.SetWebhookURL(app.URL + abono.WebhookPath) // // u, _ := svc.Checkout(ctx, abono.CheckoutRequest{Plan: "annual"}) // stripe.Pay(t, u, "ana@example.com") // the webhook has run when it returns // // It is deterministic: ids come from counters (sub_1, cus_1, cs_1) and the // clock is time.Now plus whatever Advance added. Config hands the service // the same clock, so a period that ends for the fake ends for abono too. package abonotest import ( "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "amadan.net/rastrillo/abono" "amadan.net/rastrillo/abono/internal/fakestripe" ) // Subscription is a subscription as a test puts it in Stripe: an imported // member's, or one whose status Stripe changed. An empty ID or Customer // gets the next from the counter; Email and Name are the customer's. type Subscription = fakestripe.Subscription // Stripe is one fake Stripe account, served for the length of a test. type Stripe struct { // URL is the fake's address: the API's base and the pages'. URL string // Secret signs the events it sends. Secret string fake *fakestripe.Fake } // New serves a fake Stripe until t ends. When t ends, any event that could // not be delivered fails it: an app whose webhook refuses abonotest's // events is a broken test. func New(t testing.TB) *Stripe { t.Helper() srv := httptest.NewUnstartedServer(nil) s := &Stripe{URL: "http://" + srv.Listener.Addr().String(), Secret: "whsec_abonotest"} s.fake = fakestripe.New(fakestripe.Options{WebhookSecret: s.Secret, PageBase: s.URL}) srv.Config.Handler = s.fake.Handler() srv.Start() t.Cleanup(func() { srv.Close() for _, err := range s.fake.DeliveryErrors() { t.Errorf("abonotest: %v", err) } }) return s } // Config is base pointed at this fake: Key, StripeBase and WebhookSecrets // are set, and Now too when base has none, so abono and the fake read the // same clock. func (s *Stripe) Config(base abono.Config) abono.Config { base.Key = "rk_test_abonotest" base.StripeBase = s.URL base.WebhookSecrets = []string{s.Secret} if base.Now == nil { base.Now = s.fake.Now } return base } // SetWebhookURL is where events go, usually the app's address plus // abono.WebhookPath. Until it is set, events are dropped. func (s *Stripe) SetWebhookURL(u string) { s.fake.SetWebhookURL(u) } // Now is the fake's clock. func (s *Stripe) Now() time.Time { return s.fake.Now() } // AddPrice adds a recurring price (interval "" for a one-off), named plan // on the Checkout page. Add every price the app's plans name, sold or // legacy: abono reads them. func (s *Stripe) AddPrice(id, plan string, amount int64, currency, interval string) { s.fake.AddPrice(id, plan, amount, currency, interval) } // AddPromotionCode makes code a live promotion code with id. func (s *Stripe) AddPromotionCode(code, id string) { s.fake.AddPromotionCode(code, id) } // AddSubscription puts sub in the account as it is, or replaces the one // with its id, and returns its id. No event is sent: call SendEvent for // one, or let Reconcile find it. func (s *Stripe) AddSubscription(sub Subscription) string { return s.fake.PutSubscription(sub) } // SendEvent sends an event of typ (customer.subscription.updated, say) // about subscription id as it is now. func (s *Stripe) SendEvent(typ, id string) { s.fake.SendEvent(typ, id) } // Advance moves the clock on by d and does what Stripe would have done // meanwhile, sending its events: subscriptions renew, fail (see // FailNextRenewal) or end at their period end. func (s *Stripe) Advance(d time.Duration) { s.fake.Advance(d) } // FailNextRenewal makes subscription id's next renewal fail: it goes // past_due. func (s *Stripe) FailNextRenewal(id string) { s.fake.FailNextRenewal(id) } // Pay pays at the Checkout page checkoutURL (what abono's Checkout // returned) as email, or as the address Checkout was given when email is // "". It returns the success URL Checkout sends the buyer to. The // subscription's events have been delivered by the time it returns. func (s *Stripe) Pay(t testing.TB, checkoutURL, email string) string { t.Helper() form := url.Values{} if email != "" { form.Set("email", email) } return s.post(t, checkoutURL+"/pay", form) } // Decline fails the payment at the Checkout page checkoutURL, and returns // the cancel URL Checkout sends the buyer back to. func (s *Stripe) Decline(t testing.TB, checkoutURL string) string { t.Helper() return s.post(t, checkoutURL+"/decline", nil) } // Cancel cancels subscription id at the end of its period, at the Portal // page portalURL (what abono's Portal returned). func (s *Stripe) Cancel(t testing.TB, portalURL, id string) { t.Helper() s.post(t, portalURL+"/cancel", url.Values{"subscription": {id}}) } // post sends form to one of the pages' actions, which must be one of // this fake's, and returns where it redirects. func (s *Stripe) post(t testing.TB, u string, form url.Values) string { t.Helper() if !strings.HasPrefix(u, s.URL+"/") { t.Fatalf("abonotest: %q is not one of this fake's pages (%s)", u, s.URL) } c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} resp, err := c.PostForm(u, form) if err != nil { t.Fatalf("abonotest: POST %s: %v", u, err) } defer resp.Body.Close() _, _ = io.Copy(io.Discard, resp.Body) if resp.StatusCode != http.StatusSeeOther { t.Fatalf("abonotest: POST %s: %s, want 303", u, resp.Status) } return resp.Header.Get("Location") }