rastrillo / abono Public

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

Plain git — no account needed to clone.

Download

Download this file

1package stripeapi
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "net/http"
8 "net/http/httptest"
9 "net/url"
10 "testing"
11 "time"
12)
13
14// A subscription reads the same in the API version abono pins (period
15// on the items) and an older one's webhook (on the subscription), with
16// its customer expanded or not.
17func TestSubscriptionWire(t *testing.T) {
18 t.Parallel()
19 for name, raw := range map[string]string{
20 "basil, expanded": `{"id":"sub_1","status":"active","customer":{"id":"cus_1","email":"a@example.com"},"items":{"data":[{"current_period_start":1700000000,"current_period_end":1800000000,"price":{"id":"price_A","recurring":{"interval":"year","interval_count":2}}}]}}`,
21 "older, id only": `{"id":"sub_1","status":"active","customer":"cus_1","current_period_start":1700000000,"current_period_end":1800000000,"items":{"data":[{"price":{"id":"price_A","recurring":{"interval":"year","interval_count":2}}}]}}`,
22 } {
23 var w subscriptionWire
24 if err := json.Unmarshal([]byte(raw), &w); err != nil {
25 t.Fatal(err)
26 }
27 s, err := w.decode()
28 if err != nil {
29 t.Fatal(err)
30 }
31 price, interval := s.PriceID()
32 if _, count := s.Recurring(); count != 2 {
33 t.Errorf("%s: interval_count %d, want 2", name, count)
34 }
35 if s.Customer.ID != "cus_1" || price != "price_A" || interval != "year" || !s.PeriodEnd().Equal(time.Unix(1_800_000_000, 0)) ||
36 !s.PeriodStart().Equal(time.Unix(1_700_000_000, 0)) {
37 t.Errorf("%s: %+v", name, s)
38 }
39 }
40 // Items first (ruling R21): a top-level start, later or not, is read
41 // only when no item has one, and of several items the earliest wins.
42 for name, c := range map[string]struct {
43 raw string
44 want int64
45 }{
46 "top-level later than the item": {`{"id":"sub_1","current_period_start":1750000000,"items":{"data":[{"current_period_start":1700000000}]}}`, 1_700_000_000},
47 "top-level earlier than the item": {`{"id":"sub_1","current_period_start":1650000000,"items":{"data":[{"current_period_start":1700000000}]}}`, 1_700_000_000},
48 "items disagree": {`{"id":"sub_1","items":{"data":[{"current_period_start":1710000000},{"current_period_start":1700000000}]}}`, 1_700_000_000},
49 "an item without one": {`{"id":"sub_1","current_period_start":1750000000,"items":{"data":[{},{"current_period_start":1700000000}]}}`, 1_700_000_000},
50 "only top-level": {`{"id":"sub_1","current_period_start":1750000000,"items":{"data":[{}]}}`, 1_750_000_000},
51 } {
52 var w subscriptionWire
53 if err := json.Unmarshal([]byte(c.raw), &w); err != nil {
54 t.Fatal(err)
55 }
56 s, err := w.decode()
57 if err != nil {
58 t.Fatal(err)
59 }
60 if got := s.PeriodStart(); !got.Equal(time.Unix(c.want, 0)) {
61 t.Errorf("%s: PeriodStart = %v, want %v", name, got, time.Unix(c.want, 0).UTC())
62 }
63 }
64
65 var none Subscription
66 if !none.PeriodStart().IsZero() || !none.PeriodEnd().IsZero() {
67 t.Error("a subscription with no period has a start or an end")
68 }
69}
70
71// fake serves h and returns a Client pointed at it with key "k".
72func fake(t *testing.T, h http.HandlerFunc) *Client {
73 t.Helper()
74 srv := httptest.NewServer(h)
75 t.Cleanup(srv.Close)
76 return &Client{Key: "k", Base: srv.URL}
77}
78
79func TestCallSendsPinnedVersionAndKey(t *testing.T) {
80 t.Parallel()
81 var version, auth string
82 c := fake(t, func(w http.ResponseWriter, r *http.Request) {
83 version, auth = r.Header.Get("Stripe-Version"), r.Header.Get("Authorization")
84 w.Write([]byte(`{}`))
85 })
86 if _, err := c.GetCheckout(context.Background(), "cs_1"); err != nil {
87 t.Fatal(err)
88 }
89 if version != "2025-03-31.basil" || auth != "Bearer k" {
90 t.Errorf("Stripe-Version %q, Authorization %q", version, auth)
91 }
92}
93
94func TestCallSendsIdempotencyKeyOnlyWhenGiven(t *testing.T) {
95 t.Parallel()
96 var got []string
97 var present []bool
98 c := fake(t, func(w http.ResponseWriter, r *http.Request) {
99 _, ok := r.Header["Idempotency-Key"]
100 present = append(present, ok)
101 got = append(got, r.Header.Get("Idempotency-Key"))
102 w.Write([]byte(`{}`))
103 })
104 var out struct{}
105 if err := c.call(context.Background(), http.MethodPost, "/v1/x", url.Values{"a": {"b"}}, "", &out); err != nil {
106 t.Fatal(err)
107 }
108 if err := c.call(context.Background(), http.MethodPost, "/v1/x", url.Values{"a": {"b"}}, "idem-1", &out); err != nil {
109 t.Fatal(err)
110 }
111 if len(got) != 2 || present[0] || !present[1] || got[1] != "idem-1" {
112 t.Errorf("keys %q, present %v", got, present)
113 }
114}
115
116func TestStripeErrorDecodes(t *testing.T) {
117 t.Parallel()
118 c := fake(t, func(w http.ResponseWriter, r *http.Request) {
119 w.WriteHeader(http.StatusPaymentRequired)
120 w.Write([]byte(`{"error":{"type":"card_error","code":"card_declined","message":"Your card was declined."}}`))
121 })
122 _, err := c.GetCheckout(context.Background(), "cs_1")
123 var e *Error
124 if !errors.As(err, &e) {
125 t.Fatalf("err %T %v, want *Error", err, err)
126 }
127 if *e != (Error{Status: 402, Type: "card_error", Code: "card_declined", Message: "Your card was declined."}) {
128 t.Errorf("%+v", *e)
129 }
130 if NotFound(err) {
131 t.Error("402 reported as not found")
132 }
133}
134
135func TestNotFound(t *testing.T) {
136 t.Parallel()
137 c := fake(t, func(w http.ResponseWriter, r *http.Request) {
138 w.WriteHeader(http.StatusNotFound)
139 w.Write([]byte(`{"error":{"type":"invalid_request_error","code":"resource_missing","message":"No such subscription"}}`))
140 })
141 _, err := c.GetSubscription(context.Background(), "sub_x")
142 if !NotFound(err) {
143 t.Errorf("not found not recognised: %v", err)
144 }
145}
146
147func TestListSubscriptionsPages(t *testing.T) {
148 t.Parallel()
149 var afters []string
150 c := fake(t, func(w http.ResponseWriter, r *http.Request) {
151 after := r.URL.Query().Get("starting_after")
152 afters = append(afters, after)
153 if after == "" {
154 w.Write([]byte(`{"has_more":true,"data":[{"id":"sub_1","customer":"cus_1"},{"id":"sub_2","customer":"cus_2"}]}`))
155 return
156 }
157 w.Write([]byte(`{"has_more":false,"data":[{"id":"sub_3","customer":{"id":"cus_3","email":"c@example.com"}}]}`))
158 })
159 var ids, custs []string
160 err := c.ListSubscriptions(context.Background(), func(s Subscription) error {
161 ids, custs = append(ids, s.ID), append(custs, s.Customer.ID)
162 return nil
163 })
164 if err != nil {
165 t.Fatal(err)
166 }
167 if len(ids) != 3 || ids[0] != "sub_1" || ids[2] != "sub_3" || custs[2] != "cus_3" {
168 t.Errorf("ids %v customers %v", ids, custs)
169 }
170 if len(afters) != 2 || afters[0] != "" || afters[1] != "sub_2" {
171 t.Errorf("starting_after %q, want [\"\" \"sub_2\"]", afters)
172 }
173}
174
175func TestGetPrice(t *testing.T) {
176 t.Parallel()
177 var path string
178 c := fake(t, func(w http.ResponseWriter, r *http.Request) {
179 path = r.URL.Path
180 w.Write([]byte(`{"id":"price_A","unit_amount":4900,"currency":"eur","recurring":{"interval":"month","interval_count":12}}`))
181 })
182 p, err := c.GetPrice(context.Background(), "price_A")
183 if err != nil {
184 t.Fatal(err)
185 }
186 // Every twelve months is not monthly (final review I7): the count is kept.
187 if path != "/v1/prices/price_A" || p.ID != "price_A" || p.UnitAmount != 4900 || p.Currency != "eur" ||
188 p.Recurring.Interval != "month" || p.Recurring.IntervalCount != 12 {
189 t.Errorf("%s %+v", path, p)
190 }
191}
192
193func TestCreateCheckoutAndPortalAndPromotion(t *testing.T) {
194 t.Parallel()
195 c := fake(t, func(w http.ResponseWriter, r *http.Request) {
196 r.ParseForm()
197 switch r.URL.Path {
198 case "/v1/checkout/sessions":
199 if r.Method != http.MethodPost || r.PostForm.Get("mode") != "subscription" {
200 t.Errorf("checkout: %s %v", r.Method, r.PostForm)
201 }
202 w.Write([]byte(`{"id":"cs_1","url":"https://checkout.example/cs_1"}`))
203 case "/v1/billing_portal/sessions":
204 if r.PostForm.Get("customer") != "cus_1" || r.PostForm.Get("return_url") != "https://x/back" {
205 t.Errorf("portal: %v", r.PostForm)
206 }
207 w.Write([]byte(`{"url":"https://portal.example/p"}`))
208 case "/v1/promotion_codes":
209 if r.URL.Query().Get("code") == "NONE" {
210 w.Write([]byte(`{"data":[]}`))
211 return
212 }
213 w.Write([]byte(`{"data":[{"id":"promo_1"}]}`))
214 default:
215 t.Errorf("unexpected %s", r.URL.Path)
216 }
217 })
218 ctx := context.Background()
219 cs, err := c.CreateCheckout(ctx, url.Values{"mode": {"subscription"}})
220 if err != nil || cs.ID != "cs_1" || cs.URL != "https://checkout.example/cs_1" {
221 t.Errorf("checkout %+v %v", cs, err)
222 }
223 if u, err := c.PortalURL(ctx, "cus_1", "https://x/back"); err != nil || u != "https://portal.example/p" {
224 t.Errorf("portal %q %v", u, err)
225 }
226 if id, err := c.PromotionCode(ctx, "INNOVATE"); err != nil || id != "promo_1" {
227 t.Errorf("promo %q %v", id, err)
228 }
229 if id, err := c.PromotionCode(ctx, "NONE"); err != nil || id != "" {
230 t.Errorf("no promo %q %v", id, err)
231 }
232}
233