| 1 | package stripeapi |
| 2 | |
| 3 | import ( |
| 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. |
| 17 | func 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". |
| 72 | func 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 | |
| 79 | func 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 | |
| 94 | func 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 | |
| 116 | func 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 | |
| 135 | func 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 | |
| 147 | func 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 | |
| 175 | func 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 | |
| 193 | func 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 | |