rastrillo / abono Public

Clone
git clone https://amadan.net/rastrillo/abono

Plain git — no account needed to clone.

Download

Download this file

1// Package abonotest is a fake Stripe for an app's tests to run abono
2// against end to end: the API abono calls, Checkout and Customer Portal
3// pages that pay, fail or cancel on a POST, and signed webhook events,
4// all on an httptest server on 127.0.0.1.
5//
6// stripe := abonotest.New(t)
7// stripe.AddPrice("price_annual", "annual", 6000, "eur", "year")
8// svc, _ := abono.New(stripe.Config(abono.Config{DB: db, Origin: app.URL, ...}))
9// stripe.SetWebhookURL(app.URL + abono.WebhookPath)
10//
11// u, _ := svc.Checkout(ctx, abono.CheckoutRequest{Plan: "annual"})
12// stripe.Pay(t, u, "ana@example.com") // the webhook has run when it returns
13//
14// It is deterministic: ids come from counters (sub_1, cus_1, cs_1) and the
15// clock is time.Now plus whatever Advance added. Config hands the service
16// the same clock, so a period that ends for the fake ends for abono too.
17package abonotest
18
19import (
20 "io"
21 "net/http"
22 "net/http/httptest"
23 "net/url"
24 "strings"
25 "testing"
26 "time"
27
28 "amadan.net/rastrillo/abono"
29 "amadan.net/rastrillo/abono/internal/fakestripe"
30)
31
32// Subscription is a subscription as a test puts it in Stripe: an imported
33// member's, or one whose status Stripe changed. An empty ID or Customer
34// gets the next from the counter; Email and Name are the customer's.
35type Subscription = fakestripe.Subscription
36
37// Stripe is one fake Stripe account, served for the length of a test.
38type Stripe struct {
39 // URL is the fake's address: the API's base and the pages'.
40 URL string
41 // Secret signs the events it sends.
42 Secret string
43
44 fake *fakestripe.Fake
45}
46
47// New serves a fake Stripe until t ends. When t ends, any event that could
48// not be delivered fails it: an app whose webhook refuses abonotest's
49// events is a broken test.
50func New(t testing.TB) *Stripe {
51 t.Helper()
52 srv := httptest.NewUnstartedServer(nil)
53 s := &Stripe{URL: "http://" + srv.Listener.Addr().String(), Secret: "whsec_abonotest"}
54 s.fake = fakestripe.New(fakestripe.Options{WebhookSecret: s.Secret, PageBase: s.URL})
55 srv.Config.Handler = s.fake.Handler()
56 srv.Start()
57 t.Cleanup(func() {
58 srv.Close()
59 for _, err := range s.fake.DeliveryErrors() {
60 t.Errorf("abonotest: %v", err)
61 }
62 })
63 return s
64}
65
66// Config is base pointed at this fake: Key, StripeBase and WebhookSecrets
67// are set, and Now too when base has none, so abono and the fake read the
68// same clock.
69func (s *Stripe) Config(base abono.Config) abono.Config {
70 base.Key = "rk_test_abonotest"
71 base.StripeBase = s.URL
72 base.WebhookSecrets = []string{s.Secret}
73 if base.Now == nil {
74 base.Now = s.fake.Now
75 }
76 return base
77}
78
79// SetWebhookURL is where events go, usually the app's address plus
80// abono.WebhookPath. Until it is set, events are dropped.
81func (s *Stripe) SetWebhookURL(u string) { s.fake.SetWebhookURL(u) }
82
83// Now is the fake's clock.
84func (s *Stripe) Now() time.Time { return s.fake.Now() }
85
86// AddPrice adds a recurring price (interval "" for a one-off), named plan
87// on the Checkout page. Add every price the app's plans name, sold or
88// legacy: abono reads them.
89func (s *Stripe) AddPrice(id, plan string, amount int64, currency, interval string) {
90 s.fake.AddPrice(id, plan, amount, currency, interval)
91}
92
93// AddPromotionCode makes code a live promotion code with id.
94func (s *Stripe) AddPromotionCode(code, id string) { s.fake.AddPromotionCode(code, id) }
95
96// AddSubscription puts sub in the account as it is, or replaces the one
97// with its id, and returns its id. No event is sent: call SendEvent for
98// one, or let Reconcile find it.
99func (s *Stripe) AddSubscription(sub Subscription) string { return s.fake.PutSubscription(sub) }
100
101// SendEvent sends an event of typ (customer.subscription.updated, say)
102// about subscription id as it is now.
103func (s *Stripe) SendEvent(typ, id string) { s.fake.SendEvent(typ, id) }
104
105// Advance moves the clock on by d and does what Stripe would have done
106// meanwhile, sending its events: subscriptions renew, fail (see
107// FailNextRenewal) or end at their period end.
108func (s *Stripe) Advance(d time.Duration) { s.fake.Advance(d) }
109
110// FailNextRenewal makes subscription id's next renewal fail: it goes
111// past_due.
112func (s *Stripe) FailNextRenewal(id string) { s.fake.FailNextRenewal(id) }
113
114// Pay pays at the Checkout page checkoutURL (what abono's Checkout
115// returned) as email, or as the address Checkout was given when email is
116// "". It returns the success URL Checkout sends the buyer to. The
117// subscription's events have been delivered by the time it returns.
118func (s *Stripe) Pay(t testing.TB, checkoutURL, email string) string {
119 t.Helper()
120 form := url.Values{}
121 if email != "" {
122 form.Set("email", email)
123 }
124 return s.post(t, checkoutURL+"/pay", form)
125}
126
127// Decline fails the payment at the Checkout page checkoutURL, and returns
128// the cancel URL Checkout sends the buyer back to.
129func (s *Stripe) Decline(t testing.TB, checkoutURL string) string {
130 t.Helper()
131 return s.post(t, checkoutURL+"/decline", nil)
132}
133
134// Cancel cancels subscription id at the end of its period, at the Portal
135// page portalURL (what abono's Portal returned).
136func (s *Stripe) Cancel(t testing.TB, portalURL, id string) {
137 t.Helper()
138 s.post(t, portalURL+"/cancel", url.Values{"subscription": {id}})
139}
140
141// post sends form to one of the pages' actions, which must be one of
142// this fake's, and returns where it redirects.
143func (s *Stripe) post(t testing.TB, u string, form url.Values) string {
144 t.Helper()
145 if !strings.HasPrefix(u, s.URL+"/") {
146 t.Fatalf("abonotest: %q is not one of this fake's pages (%s)", u, s.URL)
147 }
148 c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
149 resp, err := c.PostForm(u, form)
150 if err != nil {
151 t.Fatalf("abonotest: POST %s: %v", u, err)
152 }
153 defer resp.Body.Close()
154 _, _ = io.Copy(io.Discard, resp.Body)
155 if resp.StatusCode != http.StatusSeeOther {
156 t.Fatalf("abonotest: POST %s: %s, want 303", u, resp.Status)
157 }
158 return resp.Header.Get("Location")
159}
160