rastrillo / abono Public

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

Plain git — no account needed to clone.

Download

Download this file

1package abono_test
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "net/url"
9 "sync"
10 "testing"
11 "time"
12
13 "gorm.io/gorm"
14
15 "amadan.net/rastrillo/abono"
16 "amadan.net/rastrillo/abono/internal/fakestripe"
17 "amadan.net/rastrillo/abono/internal/stripeapi"
18)
19
20// stripeDouble is fakestripe served for one test, with the helpers these
21// tests set it up through: subscriptions and Checkout sessions put there
22// as they are, the prices seedPlans names, and what Checkout and the
23// Portal were asked for.
24type stripeDouble struct {
25 *fakestripe.Fake
26 URL string
27}
28
29func newStripeDouble(t *testing.T) *stripeDouble {
30 t.Helper()
31 srv := httptest.NewUnstartedServer(nil)
32 d := &stripeDouble{URL: "http://" + srv.Listener.Addr().String()}
33 // No webhook: these tests deliver the events they mean to.
34 d.Fake = fakestripe.New(fakestripe.Options{PageBase: d.URL, Now: func() time.Time { return t0 }})
35 srv.Config.Handler = d.Handler()
36 srv.Start()
37 t.Cleanup(srv.Close)
38 // The prices seedPlans names, so Checkout can learn their intervals.
39 d.price("price_A", "year", 6000, "eur")
40 d.price("price_old", "year", 4000, "eur")
41 d.price("price_M", "month", 600, "eur")
42 return d
43}
44
45func writeJSON(w http.ResponseWriter, status int, v any) {
46 w.Header().Set("Content-Type", "application/json")
47 w.WriteHeader(status)
48 _ = json.NewEncoder(w).Encode(v)
49}
50
51// service is a service over a fresh database, talking to this double.
52func (d *stripeDouble) service(t *testing.T, now *time.Time, cfg abono.Config) (*abono.Service, *testDB) {
53 t.Helper()
54 cfg.Key = "rk_test_double"
55 cfg.WebhookSecrets = []string{"whsec_double"}
56 cfg.StripeBase = d.URL
57 if cfg.Origin == "" {
58 cfg.Origin = "https://site.example"
59 }
60 return newService(t, now, cfg)
61}
62
63// subSpec is a subscription as a test describes it.
64type subSpec struct {
65 ID, Status, Price, Interval string
66 Customer, Email, Name string
67 Start, End time.Time
68 Cancel bool
69 Metadata map[string]string
70}
71
72// put stores sp in the double and returns it as the client reads it, its
73// customer expanded.
74func (d *stripeDouble) put(t *testing.T, sp subSpec) stripeapi.Subscription {
75 t.Helper()
76 if sp.Interval == "" {
77 sp.Interval = "year"
78 }
79 d.PutSubscription(fakestripe.Subscription{
80 ID: sp.ID, Customer: sp.Customer, Email: sp.Email, Name: sp.Name, Status: sp.Status,
81 Price: sp.Price, Interval: sp.Interval, PeriodStart: sp.Start, PeriodEnd: sp.End, CancelAtPeriodEnd: sp.Cancel, Metadata: sp.Metadata,
82 })
83 return d.sub(t, sp.ID)
84}
85
86// sub is subscription id as the client reads it.
87func (d *stripeDouble) sub(t *testing.T, id string) stripeapi.Subscription {
88 t.Helper()
89 b, ok := d.SubscriptionJSON(id)
90 if !ok {
91 t.Fatalf("no subscription %s in the double", id)
92 }
93 var s stripeapi.Subscription
94 if err := json.Unmarshal(b, &s); err != nil {
95 t.Fatal(err)
96 }
97 return s
98}
99
100// price stores a recurring price.
101func (d *stripeDouble) price(id, interval string, amount int64, currency string) {
102 d.AddPrice(id, "", amount, currency, interval)
103}
104
105// fetches is how many times each price has been read.
106func (d *stripeDouble) fetches() map[string]int { return d.PriceFetches() }
107
108// session stores a Checkout session. A complete one is paid, an open one
109// unpaid; paying overrides that.
110func (d *stripeDouble) session(id, status, subID, ref, email string) {
111 paying := "unpaid"
112 if status == "complete" {
113 paying = "paid"
114 }
115 d.PutSession(fakestripe.Session{ID: id, Status: status, PaymentStatus: paying, Subscription: subID,
116 ClientReferenceID: ref, Email: email})
117}
118
119// paying sets a session's payment_status.
120func (d *stripeDouble) paying(id, status string) {
121 s, _ := d.Session(id)
122 s.PaymentStatus = status
123 d.PutSession(s)
124}
125
126func (d *stripeDouble) lastCheckout(t *testing.T) url.Values {
127 t.Helper()
128 forms := d.CheckoutForms()
129 if len(forms) == 0 {
130 t.Fatal("no Checkout session was created")
131 }
132 return forms[len(forms)-1]
133}
134
135// recorder is an app's Holder and OnChange, counting what it was asked.
136type recorder struct {
137 mu sync.Mutex
138 subject string // what Holder answers
139 asked []string
140 changes []string
141
142 // pause, when set, runs at the start of every Holder call.
143 pause func()
144}
145
146func (r *recorder) Holder(_ context.Context, _ *gorm.DB, email, _ string) (string, error) {
147 if r.pause != nil {
148 r.pause()
149 }
150 r.mu.Lock()
151 defer r.mu.Unlock()
152 r.asked = append(r.asked, email)
153 return r.subject, nil
154}
155
156func (r *recorder) OnChange(_ context.Context, _ *gorm.DB, subject string) error {
157 r.mu.Lock()
158 defer r.mu.Unlock()
159 r.changes = append(r.changes, subject)
160 return nil
161}
162
163func (r *recorder) config() abono.Config {
164 return abono.Config{Holder: r.Holder, OnChange: r.OnChange}
165}
166
167// take returns what Holder and OnChange were given since the last take.
168func (r *recorder) take() (asked, changes []string) {
169 r.mu.Lock()
170 defer r.mu.Unlock()
171 asked, changes = r.asked, r.changes
172 r.asked, r.changes = nil, nil
173 return asked, changes
174}
175