| 1 | package fakestripe_test |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "io" |
| 8 | "net/http" |
| 9 | "net/http/httptest" |
| 10 | "net/url" |
| 11 | "reflect" |
| 12 | "regexp" |
| 13 | "strings" |
| 14 | "sync" |
| 15 | "testing" |
| 16 | "time" |
| 17 | |
| 18 | "amadan.net/rastrillo/abono/internal/fakestripe" |
| 19 | "amadan.net/rastrillo/abono/internal/stripeapi" |
| 20 | ) |
| 21 | |
| 22 | // t0 is the fake's clock before any Advance. |
| 23 | var t0 = time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC) |
| 24 | |
| 25 | const secret = "whsec_fake" |
| 26 | |
| 27 | // delivery is one event the fake POSTed. |
| 28 | type delivery struct { |
| 29 | signature string |
| 30 | body []byte |
| 31 | } |
| 32 | |
| 33 | // hook is a webhook endpoint that keeps what it is sent. |
| 34 | type hook struct { |
| 35 | mu sync.Mutex |
| 36 | got []delivery |
| 37 | code int // what it answers; 0 is 200 |
| 38 | } |
| 39 | |
| 40 | func (h *hook) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| 41 | body, _ := io.ReadAll(r.Body) |
| 42 | h.mu.Lock() |
| 43 | defer h.mu.Unlock() |
| 44 | h.got = append(h.got, delivery{r.Header.Get("Stripe-Signature"), body}) |
| 45 | if h.code != 0 { |
| 46 | w.WriteHeader(h.code) |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | // take returns what arrived since the last take. |
| 51 | func (h *hook) take() []delivery { |
| 52 | h.mu.Lock() |
| 53 | defer h.mu.Unlock() |
| 54 | got := h.got |
| 55 | h.got = nil |
| 56 | return got |
| 57 | } |
| 58 | |
| 59 | // types is each delivery's event type, checking every signature on the |
| 60 | // way. |
| 61 | func types(t *testing.T, ds []delivery, now time.Time) []string { |
| 62 | t.Helper() |
| 63 | var out []string |
| 64 | for _, d := range ds { |
| 65 | if err := stripeapi.VerifySignature(d.signature, d.body, []string{secret}, now); err != nil { |
| 66 | t.Errorf("event %s: signature does not verify: %v", d.body, err) |
| 67 | } |
| 68 | if err := stripeapi.VerifySignature(d.signature, d.body, []string{"whsec_other"}, now); err == nil { |
| 69 | t.Errorf("event %s verifies with another secret", d.body) |
| 70 | } |
| 71 | var e struct { |
| 72 | Type string `json:"type"` |
| 73 | } |
| 74 | if err := json.Unmarshal(d.body, &e); err != nil || e.Type == "" { |
| 75 | t.Fatalf("not an event: %s", d.body) |
| 76 | } |
| 77 | out = append(out, e.Type) |
| 78 | } |
| 79 | return out |
| 80 | } |
| 81 | |
| 82 | type rig struct { |
| 83 | f *fakestripe.Fake |
| 84 | base string |
| 85 | client *stripeapi.Client |
| 86 | hook *hook |
| 87 | } |
| 88 | |
| 89 | // start serves a fake on 127.0.0.1 whose pages are at its own address, |
| 90 | // sending its events to a hook. |
| 91 | func start(t *testing.T) *rig { |
| 92 | t.Helper() |
| 93 | h := &hook{} |
| 94 | hooks := httptest.NewServer(h) |
| 95 | t.Cleanup(hooks.Close) |
| 96 | |
| 97 | srv := httptest.NewUnstartedServer(nil) |
| 98 | base := "http://" + srv.Listener.Addr().String() |
| 99 | f := fakestripe.New(fakestripe.Options{ |
| 100 | WebhookURL: hooks.URL, WebhookSecret: secret, PageBase: base, |
| 101 | Now: func() time.Time { return t0 }, |
| 102 | }) |
| 103 | srv.Config.Handler = f.Handler() |
| 104 | srv.Start() |
| 105 | t.Cleanup(srv.Close) |
| 106 | f.AddPrice("price_A", "annual", 6000, "eur", "year") |
| 107 | return &rig{f: f, base: base, client: &stripeapi.Client{Key: "rk_test_fake", Base: base}, hook: h} |
| 108 | } |
| 109 | |
| 110 | // noRedirects is a client that reports a redirect rather than following |
| 111 | // it. |
| 112 | var noRedirects = &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} |
| 113 | |
| 114 | func get(t *testing.T, u string) (int, string) { |
| 115 | t.Helper() |
| 116 | resp, err := noRedirects.Get(u) |
| 117 | if err != nil { |
| 118 | t.Fatalf("GET %s: %v", u, err) |
| 119 | } |
| 120 | defer resp.Body.Close() |
| 121 | body, _ := io.ReadAll(resp.Body) |
| 122 | return resp.StatusCode, string(body) |
| 123 | } |
| 124 | |
| 125 | func post(t *testing.T, u string, form url.Values) (int, string) { |
| 126 | t.Helper() |
| 127 | resp, err := noRedirects.PostForm(u, form) |
| 128 | if err != nil { |
| 129 | t.Fatalf("POST %s: %v", u, err) |
| 130 | } |
| 131 | defer resp.Body.Close() |
| 132 | _, _ = io.Copy(io.Discard, resp.Body) |
| 133 | return resp.StatusCode, resp.Header.Get("Location") |
| 134 | } |
| 135 | |
| 136 | // keysOnly checks that every piece of text on a page is a message key: |
| 137 | // the pages carry no English of their own (ruling R20). |
| 138 | func keysOnly(t *testing.T, page string, want ...string) { |
| 139 | t.Helper() |
| 140 | for _, m := range regexp.MustCompile(`>([^<]+)<`).FindAllStringSubmatch(page, -1) { |
| 141 | if s := strings.TrimSpace(m[1]); s != "" && !strings.HasPrefix(s, "abono.") { |
| 142 | t.Errorf("the page says %q, which is not a message key", s) |
| 143 | } |
| 144 | } |
| 145 | for _, key := range want { |
| 146 | if !strings.Contains(page, ">"+key+"<") { |
| 147 | t.Errorf("the page does not show %s:\n%s", key, page) |
| 148 | } |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | // checkout starts a Checkout for ana on price_A, as abono does. |
| 153 | func (r *rig) checkout(t *testing.T) stripeapi.CheckoutSession { |
| 154 | t.Helper() |
| 155 | cs, err := r.client.CreateCheckout(context.Background(), url.Values{ |
| 156 | "mode": {"subscription"}, |
| 157 | "line_items[0][price]": {"price_A"}, |
| 158 | "line_items[0][quantity]": {"1"}, |
| 159 | "success_url": {"https://site.example/done?session_id={CHECKOUT_SESSION_ID}"}, |
| 160 | "cancel_url": {"https://site.example/join"}, |
| 161 | "client_reference_id": {"ana"}, |
| 162 | "customer_email": {"ana@example.com"}, |
| 163 | "subscription_data[metadata][abono_subject]": {"ana"}, |
| 164 | }) |
| 165 | if err != nil { |
| 166 | t.Fatalf("CreateCheckout: %v", err) |
| 167 | } |
| 168 | return cs |
| 169 | } |
| 170 | |
| 171 | // subscribe checks out and pays, and returns the subscription's id. |
| 172 | func (r *rig) subscribe(t *testing.T) string { |
| 173 | t.Helper() |
| 174 | cs := r.checkout(t) |
| 175 | if code, _ := post(t, cs.URL+"/pay", nil); code != http.StatusSeeOther { |
| 176 | t.Fatalf("Pay: status %d", code) |
| 177 | } |
| 178 | got, err := r.client.GetCheckout(context.Background(), cs.ID) |
| 179 | if err != nil { |
| 180 | t.Fatalf("GetCheckout: %v", err) |
| 181 | } |
| 182 | r.hook.take() |
| 183 | return got.Subscription |
| 184 | } |
| 185 | |
| 186 | func (r *rig) sub(t *testing.T, id string) stripeapi.Subscription { |
| 187 | t.Helper() |
| 188 | s, err := r.client.GetSubscription(context.Background(), id) |
| 189 | if err != nil { |
| 190 | t.Fatalf("GetSubscription(%s): %v", id, err) |
| 191 | } |
| 192 | return s |
| 193 | } |
| 194 | |
| 195 | func TestFakeCheckoutPayCreatesAnActiveSubscriptionAndSignsAnEvent(t *testing.T) { |
| 196 | r := start(t) |
| 197 | ctx := context.Background() |
| 198 | |
| 199 | cs := r.checkout(t) |
| 200 | if cs.ID != "cs_1" || cs.URL != r.base+"/checkout/cs_1" || cs.Status != "open" || cs.PaymentStatus != "unpaid" { |
| 201 | t.Fatalf("session = %+v", cs) |
| 202 | } |
| 203 | code, page := get(t, cs.URL) |
| 204 | if code != http.StatusOK { |
| 205 | t.Fatalf("checkout page: status %d", code) |
| 206 | } |
| 207 | keysOnly(t, page, "abono.sandbox.banner", "abono.sandbox.checkout_title", "abono.sandbox.checkout_intro", |
| 208 | "abono.sandbox.checkout_item", "abono.sandbox.pay", "abono.sandbox.decline", "abono.sandbox.leave") |
| 209 | if !strings.Contains(page, `href="https://site.example/join"`) { |
| 210 | t.Errorf("the page does not lead back to the cancel URL:\n%s", page) |
| 211 | } |
| 212 | if len(r.hook.take()) != 0 { |
| 213 | t.Fatal("an event was sent before paying") |
| 214 | } |
| 215 | |
| 216 | code, loc := post(t, cs.URL+"/pay", nil) |
| 217 | if code != http.StatusSeeOther || loc != "https://site.example/done?session_id=cs_1" { |
| 218 | t.Fatalf("Pay: %d to %q, want 303 to the success URL with the session id", code, loc) |
| 219 | } |
| 220 | |
| 221 | sub := r.sub(t, "sub_1") |
| 222 | price, interval := sub.PriceID() |
| 223 | if sub.Status != "active" || sub.CancelAtPeriodEnd || sub.Customer.ID != "cus_1" || sub.Customer.Email != "ana@example.com" || |
| 224 | price != "price_A" || interval != "year" || sub.Metadata["abono_subject"] != "ana" { |
| 225 | t.Errorf("subscription = %+v", sub) |
| 226 | } |
| 227 | // 2025-03-31.basil: the period end is on the items, not the subscription. |
| 228 | if !sub.PeriodStart().Equal(t0) { |
| 229 | t.Errorf("period start = %v, want %v", sub.PeriodStart(), t0) |
| 230 | } |
| 231 | if sub.CurrentPeriodEnd != 0 || !sub.PeriodEnd().Equal(t0.AddDate(1, 0, 0)) { |
| 232 | t.Errorf("period end: top level %d, items %v; want only the items, a year on", sub.CurrentPeriodEnd, sub.PeriodEnd()) |
| 233 | } |
| 234 | done, err := r.client.GetCheckout(ctx, "cs_1") |
| 235 | if err != nil { |
| 236 | t.Fatalf("GetCheckout: %v", err) |
| 237 | } |
| 238 | if done.Status != "complete" || done.PaymentStatus != "paid" || done.Subscription != "sub_1" || |
| 239 | done.ClientReferenceID != "ana" || done.CustomerDetails.Email != "ana@example.com" { |
| 240 | t.Errorf("finished session = %+v", done) |
| 241 | } |
| 242 | |
| 243 | if got, want := types(t, r.hook.take(), t0), []string{"customer.subscription.created", "checkout.session.completed"}; !reflect.DeepEqual(got, want) { |
| 244 | t.Errorf("events = %v, want %v", got, want) |
| 245 | } |
| 246 | |
| 247 | // Paid is paid: paying again (a resubmitted form, or a retry after a |
| 248 | // failed delivery) goes to the success page again and buys nothing, |
| 249 | // and the page and its buttons are gone. |
| 250 | if code, loc := post(t, cs.URL+"/pay", nil); code != http.StatusSeeOther || loc != "https://site.example/done?session_id=cs_1" { |
| 251 | t.Errorf("paying twice: %d to %q, want 303 to the success URL", code, loc) |
| 252 | } |
| 253 | if _, err := r.client.GetSubscription(ctx, "sub_2"); !stripeapi.NotFound(err) { |
| 254 | t.Errorf("paying twice made a second subscription: %v", err) |
| 255 | } |
| 256 | code, page = get(t, cs.URL) |
| 257 | if code != http.StatusGone { |
| 258 | t.Errorf("a paid session's page: status %d, want 410", code) |
| 259 | } |
| 260 | keysOnly(t, page, "abono.sandbox.banner", "abono.sandbox.expired") |
| 261 | if len(r.hook.take()) != 0 { |
| 262 | t.Error("paying twice sent events") |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | func TestFakeCheckoutDeclineMakesNothing(t *testing.T) { |
| 267 | r := start(t) |
| 268 | cs := r.checkout(t) |
| 269 | code, loc := post(t, cs.URL+"/decline", nil) |
| 270 | if code != http.StatusSeeOther || loc != "https://site.example/join" { |
| 271 | t.Fatalf("Decline: %d to %q, want 303 to the cancel URL", code, loc) |
| 272 | } |
| 273 | got, err := r.client.GetCheckout(context.Background(), cs.ID) |
| 274 | if err != nil { |
| 275 | t.Fatalf("GetCheckout: %v", err) |
| 276 | } |
| 277 | if got.Status != "expired" || got.Subscription != "" { |
| 278 | t.Errorf("declined session = %+v", got) |
| 279 | } |
| 280 | if _, err := r.client.GetSubscription(context.Background(), "sub_1"); !stripeapi.NotFound(err) { |
| 281 | t.Errorf("a subscription after declining: %v", err) |
| 282 | } |
| 283 | if got, want := types(t, r.hook.take(), t0), []string{"checkout.session.expired"}; !reflect.DeepEqual(got, want) { |
| 284 | t.Errorf("events = %v, want %v", got, want) |
| 285 | } |
| 286 | if code, _ := get(t, r.base+"/checkout/cs_404"); code != http.StatusNotFound { |
| 287 | t.Errorf("an unknown session's page: status %d, want 404", code) |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | func TestFakeRenewalFailureGoesPastDue(t *testing.T) { |
| 292 | r := start(t) |
| 293 | id := r.subscribe(t) |
| 294 | |
| 295 | // A renewal that is paid moves the period on. |
| 296 | r.f.Advance(366 * 24 * time.Hour) |
| 297 | now := t0.Add(366 * 24 * time.Hour) |
| 298 | sub := r.sub(t, id) |
| 299 | if sub.Status != "active" || !sub.PeriodEnd().Equal(t0.AddDate(2, 0, 0)) { |
| 300 | t.Errorf("after a paid renewal: %s until %v", sub.Status, sub.PeriodEnd()) |
| 301 | } |
| 302 | if got, want := types(t, r.hook.take(), now), []string{"invoice.paid", "customer.subscription.updated"}; !reflect.DeepEqual(got, want) { |
| 303 | t.Errorf("renewal events = %v, want %v", got, want) |
| 304 | } |
| 305 | |
| 306 | // The next one fails: past_due, and like Stripe the period still |
| 307 | // moves on, to the one the failed invoice was for. |
| 308 | r.f.FailNextRenewal(id) |
| 309 | r.f.Advance(366 * 24 * time.Hour) |
| 310 | now = now.Add(366 * 24 * time.Hour) |
| 311 | sub = r.sub(t, id) |
| 312 | if sub.Status != "past_due" || !sub.PeriodEnd().Equal(t0.AddDate(3, 0, 0)) { |
| 313 | t.Errorf("after a failed renewal: %s until %v, want past_due until %v", sub.Status, sub.PeriodEnd(), t0.AddDate(3, 0, 0)) |
| 314 | } |
| 315 | // The unpaid period starts where the paid one ended: abono counts its |
| 316 | // 30 days from here (ruling R21). |
| 317 | if !sub.PeriodStart().Equal(t0.AddDate(2, 0, 0)) { |
| 318 | t.Errorf("after a failed renewal the period starts %v, want %v", sub.PeriodStart(), t0.AddDate(2, 0, 0)) |
| 319 | } |
| 320 | if got, want := types(t, r.hook.take(), now), []string{"invoice.payment_failed", "customer.subscription.updated"}; !reflect.DeepEqual(got, want) { |
| 321 | t.Errorf("failure events = %v, want %v", got, want) |
| 322 | } |
| 323 | |
| 324 | // Stripe keeps invoicing a past_due subscription (an account may leave |
| 325 | // it past_due for good): the next renewal fails too, and the period |
| 326 | // moves on again. abono must not count its 30 days from this new start |
| 327 | // (ruling R32). |
| 328 | r.f.Advance(366 * 24 * time.Hour) |
| 329 | now = now.Add(366 * 24 * time.Hour) |
| 330 | sub = r.sub(t, id) |
| 331 | if sub.Status != "past_due" || !sub.PeriodStart().Equal(t0.AddDate(3, 0, 0)) || !sub.PeriodEnd().Equal(t0.AddDate(4, 0, 0)) { |
| 332 | t.Errorf("a period later: %s from %v until %v, want past_due from %v until %v", |
| 333 | sub.Status, sub.PeriodStart(), sub.PeriodEnd(), t0.AddDate(3, 0, 0), t0.AddDate(4, 0, 0)) |
| 334 | } |
| 335 | if got, want := types(t, r.hook.take(), now), []string{"invoice.payment_failed", "customer.subscription.updated"}; !reflect.DeepEqual(got, want) { |
| 336 | t.Errorf("events of the second failed renewal = %v, want %v", got, want) |
| 337 | } |
| 338 | } |
| 339 | |
| 340 | // TestFakeRenewsOnTheBillingAnchor is Stripe's month-end rule: a monthly |
| 341 | // subscription anchored on the 31st renews on the last day of a shorter |
| 342 | // month, then on the 31st again, never drifting to the 3rd. |
| 343 | func TestFakeRenewsOnTheBillingAnchor(t *testing.T) { |
| 344 | jan31 := time.Date(2027, 1, 31, 12, 0, 0, 0, time.UTC) |
| 345 | f := fakestripe.New(fakestripe.Options{Now: func() time.Time { return jan31 }}) |
| 346 | f.AddPrice("price_M", "monthly", 600, "eur", "month") |
| 347 | id := f.PutSubscription(fakestripe.Subscription{Status: "active", Price: "price_M", |
| 348 | PeriodStart: time.Date(2026, 12, 31, 12, 0, 0, 0, time.UTC), PeriodEnd: jan31}) |
| 349 | |
| 350 | period := func() (time.Time, time.Time) { |
| 351 | t.Helper() |
| 352 | b, ok := f.SubscriptionJSON(id) |
| 353 | if !ok { |
| 354 | t.Fatal("the subscription is gone") |
| 355 | } |
| 356 | var s stripeapi.Subscription |
| 357 | if err := json.Unmarshal(b, &s); err != nil { |
| 358 | t.Fatal(err) |
| 359 | } |
| 360 | return s.PeriodStart(), s.PeriodEnd() |
| 361 | } |
| 362 | f.Tick() |
| 363 | feb28 := time.Date(2027, 2, 28, 12, 0, 0, 0, time.UTC) |
| 364 | if start, end := period(); !start.Equal(jan31) || !end.Equal(feb28) { |
| 365 | t.Errorf("renewed on 31 January: %v to %v, want %v to %v", start, end, jan31, feb28) |
| 366 | } |
| 367 | f.Advance(feb28.Sub(jan31)) |
| 368 | mar31 := time.Date(2027, 3, 31, 12, 0, 0, 0, time.UTC) |
| 369 | if start, end := period(); !start.Equal(feb28) || !end.Equal(mar31) { |
| 370 | t.Errorf("renewed on 28 February: %v to %v, want %v to %v", start, end, feb28, mar31) |
| 371 | } |
| 372 | } |
| 373 | |
| 374 | func TestFakePortalCancelSetsCancelAtPeriodEnd(t *testing.T) { |
| 375 | r := start(t) |
| 376 | ctx := context.Background() |
| 377 | id := r.subscribe(t) |
| 378 | |
| 379 | u, err := r.client.PortalURL(ctx, "cus_1", "https://site.example/account") |
| 380 | if err != nil { |
| 381 | t.Fatalf("PortalURL: %v", err) |
| 382 | } |
| 383 | if u != r.base+"/portal/cus_1" { |
| 384 | t.Fatalf("portal URL = %q", u) |
| 385 | } |
| 386 | code, page := get(t, u) |
| 387 | if code != http.StatusOK { |
| 388 | t.Fatalf("portal page: status %d", code) |
| 389 | } |
| 390 | keysOnly(t, page, "abono.sandbox.banner", "abono.sandbox.portal_title", "abono.sandbox.portal_intro", |
| 391 | "abono.sandbox.portal_plan", "abono.sandbox.portal_cancel", "abono.sandbox.portal_return") |
| 392 | if !strings.Contains(page, `href="https://site.example/account"`) || !strings.Contains(page, `value="`+id+`"`) { |
| 393 | t.Errorf("the page lacks the return link or the subscription:\n%s", page) |
| 394 | } |
| 395 | |
| 396 | code, loc := post(t, u+"/cancel", url.Values{"subscription": {id}}) |
| 397 | if code != http.StatusSeeOther || loc != u { |
| 398 | t.Fatalf("Cancel: %d to %q, want 303 back to the portal", code, loc) |
| 399 | } |
| 400 | if sub := r.sub(t, id); sub.Status != "active" || !sub.CancelAtPeriodEnd { |
| 401 | t.Errorf("after cancelling: %s, cancel_at_period_end %v", sub.Status, sub.CancelAtPeriodEnd) |
| 402 | } |
| 403 | if got, want := types(t, r.hook.take(), t0), []string{"customer.subscription.updated"}; !reflect.DeepEqual(got, want) { |
| 404 | t.Errorf("events = %v, want %v", got, want) |
| 405 | } |
| 406 | if _, page = get(t, u); strings.Contains(page, "abono.sandbox.portal_cancel") { |
| 407 | t.Error("a cancelling subscription can still be cancelled") |
| 408 | } |
| 409 | |
| 410 | // Somebody else's subscription is not this customer's to cancel. |
| 411 | if code, _ := post(t, r.base+"/portal/cus_2/cancel", url.Values{"subscription": {id}}); code != http.StatusNotFound { |
| 412 | t.Errorf("cancelling another customer's subscription: status %d, want 404", code) |
| 413 | } |
| 414 | |
| 415 | // At the period end it ends. |
| 416 | r.f.Advance(366 * 24 * time.Hour) |
| 417 | if sub := r.sub(t, id); sub.Status != "canceled" { |
| 418 | t.Errorf("past the period end: %s, want canceled", sub.Status) |
| 419 | } |
| 420 | if got, want := types(t, r.hook.take(), t0.Add(366*24*time.Hour)), []string{"customer.subscription.deleted"}; !reflect.DeepEqual(got, want) { |
| 421 | t.Errorf("events = %v, want %v", got, want) |
| 422 | } |
| 423 | } |
| 424 | |
| 425 | func TestFakeAPIIsStripes(t *testing.T) { |
| 426 | r := start(t) |
| 427 | ctx := context.Background() |
| 428 | |
| 429 | // A key and the pinned version are required, as they are of abono. |
| 430 | req, _ := http.NewRequest(http.MethodGet, r.base+"/v1/prices/price_A", nil) |
| 431 | resp, err := http.DefaultClient.Do(req) |
| 432 | if err != nil { |
| 433 | t.Fatal(err) |
| 434 | } |
| 435 | resp.Body.Close() |
| 436 | if resp.StatusCode != http.StatusUnauthorized { |
| 437 | t.Errorf("no key: status %d, want 401", resp.StatusCode) |
| 438 | } |
| 439 | req.Header.Set("Authorization", "Bearer rk_test_fake") |
| 440 | req.Header.Set("Stripe-Version", "2020-08-27") |
| 441 | if resp, err = http.DefaultClient.Do(req); err != nil { |
| 442 | t.Fatal(err) |
| 443 | } |
| 444 | resp.Body.Close() |
| 445 | if resp.StatusCode != http.StatusBadRequest { |
| 446 | t.Errorf("another API version: status %d, want 400", resp.StatusCode) |
| 447 | } |
| 448 | |
| 449 | p, err := r.client.GetPrice(ctx, "price_A") |
| 450 | if err != nil || p.UnitAmount != 6000 || p.Currency != "eur" || p.Recurring.Interval != "year" { |
| 451 | t.Errorf("GetPrice = %+v, %v", p, err) |
| 452 | } |
| 453 | if _, err := r.client.GetPrice(ctx, "price_nope"); !stripeapi.NotFound(err) { |
| 454 | t.Errorf("an unknown price: %v, want not found", err) |
| 455 | } |
| 456 | r.f.AddPromotionCode("INNOVATE", "promo_1") |
| 457 | if id, err := r.client.PromotionCode(ctx, "INNOVATE"); err != nil || id != "promo_1" { |
| 458 | t.Errorf("PromotionCode = %q, %v", id, err) |
| 459 | } |
| 460 | if id, err := r.client.PromotionCode(ctx, "NOPE"); err != nil || id != "" { |
| 461 | t.Errorf("an unknown code = %q, %v", id, err) |
| 462 | } |
| 463 | |
| 464 | // Ids come from a counter, and the list holds every subscription. |
| 465 | for range 3 { |
| 466 | r.subscribe(t) |
| 467 | } |
| 468 | var ids []string |
| 469 | if err := r.client.ListSubscriptions(ctx, func(s stripeapi.Subscription) error { |
| 470 | ids = append(ids, s.ID+"/"+s.Customer.Email) |
| 471 | return nil |
| 472 | }); err != nil { |
| 473 | t.Fatalf("ListSubscriptions: %v", err) |
| 474 | } |
| 475 | if want := []string{"sub_1/ana@example.com", "sub_2/ana@example.com", "sub_3/ana@example.com"}; !reflect.DeepEqual(ids, want) { |
| 476 | t.Errorf("listed %v, want %v", ids, want) |
| 477 | } |
| 478 | } |
| 479 | |
| 480 | func TestFakeKeepsAFailedWebhookDeliveryForLater(t *testing.T) { |
| 481 | r := start(t) |
| 482 | r.hook.mu.Lock() |
| 483 | r.hook.code = http.StatusInternalServerError |
| 484 | r.hook.mu.Unlock() |
| 485 | |
| 486 | // Like Stripe, Checkout carries on when the webhook fails; the event |
| 487 | // waits, and the rest wait behind it. |
| 488 | cs := r.checkout(t) |
| 489 | if code, _ := post(t, cs.URL+"/pay", nil); code != http.StatusSeeOther { |
| 490 | t.Fatalf("Pay with a failing webhook: status %d, want 303", code) |
| 491 | } |
| 492 | if errs := r.f.DeliveryErrors(); len(errs) != 1 { |
| 493 | t.Errorf("delivery errors = %v, want one", errs) |
| 494 | } |
| 495 | if got := len(r.hook.take()); got != 1 { |
| 496 | t.Errorf("attempts = %d, want 1: the second event waits behind the first", got) |
| 497 | } |
| 498 | |
| 499 | r.hook.mu.Lock() |
| 500 | r.hook.code = 0 |
| 501 | r.hook.mu.Unlock() |
| 502 | r.f.Tick() |
| 503 | if got, want := types(t, r.hook.take(), t0), []string{"customer.subscription.created", "checkout.session.completed"}; !reflect.DeepEqual(got, want) { |
| 504 | t.Errorf("redelivered %v, want %v in order", got, want) |
| 505 | } |
| 506 | if errs := r.f.DeliveryErrors(); len(errs) != 0 { |
| 507 | t.Errorf("delivery errors after redelivery = %v, want none", errs) |
| 508 | } |
| 509 | } |
| 510 | |
| 511 | // A sandbox's in-process delivery that fails makes the action fail, and |
| 512 | // the event waits: the next action sends it first, without redoing the |
| 513 | // action. |
| 514 | func TestFakeRetriesAFailedInProcessDelivery(t *testing.T) { |
| 515 | var mu sync.Mutex |
| 516 | var got []string |
| 517 | failNext := false |
| 518 | deliver := func(_ context.Context, body []byte) error { |
| 519 | var e struct { |
| 520 | Type string `json:"type"` |
| 521 | } |
| 522 | _ = json.Unmarshal(body, &e) |
| 523 | mu.Lock() |
| 524 | defer mu.Unlock() |
| 525 | if failNext { |
| 526 | failNext = false |
| 527 | return errors.New("the app's OnChange failed") |
| 528 | } |
| 529 | got = append(got, e.Type) |
| 530 | return nil |
| 531 | } |
| 532 | srv := httptest.NewUnstartedServer(nil) |
| 533 | base := "http://" + srv.Listener.Addr().String() |
| 534 | f := fakestripe.New(fakestripe.Options{Deliver: deliver, PageBase: base, Now: func() time.Time { return t0 }}) |
| 535 | srv.Config.Handler = f.Handler() |
| 536 | srv.Start() |
| 537 | t.Cleanup(srv.Close) |
| 538 | f.AddPrice("price_A", "annual", 6000, "eur", "year") |
| 539 | r := &rig{f: f, base: base, client: &stripeapi.Client{Key: "rk_test_fake", Base: base}, hook: &hook{}} |
| 540 | id := r.subscribe(t) |
| 541 | u, err := r.client.PortalURL(context.Background(), "cus_1", "https://site.example/account") |
| 542 | if err != nil { |
| 543 | t.Fatal(err) |
| 544 | } |
| 545 | mu.Lock() |
| 546 | got, failNext = nil, true |
| 547 | mu.Unlock() |
| 548 | |
| 549 | if code, _ := post(t, u+"/cancel", url.Values{"subscription": {id}}); code != http.StatusInternalServerError { |
| 550 | t.Fatalf("Cancel whose delivery failed: status %d, want 500", code) |
| 551 | } |
| 552 | if len(f.DeliveryErrors()) != 1 { |
| 553 | t.Errorf("delivery errors = %v, want one", f.DeliveryErrors()) |
| 554 | } |
| 555 | code, loc := post(t, u+"/cancel", url.Values{"subscription": {id}}) |
| 556 | if code != http.StatusSeeOther || loc != u { |
| 557 | t.Fatalf("Cancel again: %d to %q, want 303 back to the portal", code, loc) |
| 558 | } |
| 559 | mu.Lock() |
| 560 | defer mu.Unlock() |
| 561 | if want := []string{"customer.subscription.updated"}; !reflect.DeepEqual(got, want) { |
| 562 | t.Errorf("delivered %v, want the one queued event once", got) |
| 563 | } |
| 564 | if sub := r.sub(t, id); sub.Status != "active" || !sub.CancelAtPeriodEnd { |
| 565 | t.Errorf("subscription = %s, cancelling %v", sub.Status, sub.CancelAtPeriodEnd) |
| 566 | } |
| 567 | var ids []string |
| 568 | _ = r.client.ListSubscriptions(context.Background(), func(s stripeapi.Subscription) error { ids = append(ids, s.ID); return nil }) |
| 569 | if !reflect.DeepEqual(ids, []string{id}) { |
| 570 | t.Errorf("subscriptions = %v, want only %s", ids, id) |
| 571 | } |
| 572 | } |
| 573 | |
| 574 | // A subscription cancelled while past_due ends at its period end too. |
| 575 | func TestFakeCancelAfterAFailedRenewalEnds(t *testing.T) { |
| 576 | r := start(t) |
| 577 | id := r.subscribe(t) |
| 578 | r.f.FailNextRenewal(id) |
| 579 | r.f.Advance(366 * 24 * time.Hour) |
| 580 | if sub := r.sub(t, id); sub.Status != "past_due" { |
| 581 | t.Fatalf("after a failed renewal: %s", sub.Status) |
| 582 | } |
| 583 | r.hook.take() |
| 584 | |
| 585 | u, err := r.client.PortalURL(context.Background(), "cus_1", "https://site.example/account") |
| 586 | if err != nil { |
| 587 | t.Fatal(err) |
| 588 | } |
| 589 | if code, _ := post(t, u+"/cancel", url.Values{"subscription": {id}}); code != http.StatusSeeOther { |
| 590 | t.Fatalf("Cancel: status %d", code) |
| 591 | } |
| 592 | if sub := r.sub(t, id); sub.Status != "past_due" || !sub.CancelAtPeriodEnd { |
| 593 | t.Errorf("after cancelling: %s, cancelling %v", sub.Status, sub.CancelAtPeriodEnd) |
| 594 | } |
| 595 | if got, want := types(t, r.hook.take(), t0.Add(366*24*time.Hour)), []string{"customer.subscription.updated"}; !reflect.DeepEqual(got, want) { |
| 596 | t.Errorf("events = %v, want %v", got, want) |
| 597 | } |
| 598 | r.f.Advance(366 * 24 * time.Hour) |
| 599 | if sub := r.sub(t, id); sub.Status != "canceled" { |
| 600 | t.Errorf("past the period end: %s, want canceled", sub.Status) |
| 601 | } |
| 602 | if got, want := types(t, r.hook.take(), t0.Add(2*366*24*time.Hour)), []string{"customer.subscription.deleted"}; !reflect.DeepEqual(got, want) { |
| 603 | t.Errorf("events = %v, want %v", got, want) |
| 604 | } |
| 605 | } |
| 606 | |
| 607 | // anonymousCheckout starts a Checkout for someone who is not signed in: |
| 608 | // no customer, no email, no subject. |
| 609 | func (r *rig) anonymousCheckout(t *testing.T) stripeapi.CheckoutSession { |
| 610 | t.Helper() |
| 611 | cs, err := r.client.CreateCheckout(context.Background(), url.Values{ |
| 612 | "mode": {"subscription"}, |
| 613 | "line_items[0][price]": {"price_A"}, |
| 614 | "line_items[0][quantity]": {"1"}, |
| 615 | "success_url": {"https://site.example/done?session_id={CHECKOUT_SESSION_ID}"}, |
| 616 | "cancel_url": {"https://site.example/join"}, |
| 617 | }) |
| 618 | if err != nil { |
| 619 | t.Fatalf("CreateCheckout: %v", err) |
| 620 | } |
| 621 | return cs |
| 622 | } |
| 623 | |
| 624 | // postPage posts form and returns the status, the Location and the body. |
| 625 | func postPage(t *testing.T, u string, form url.Values) (int, string, string) { |
| 626 | t.Helper() |
| 627 | resp, err := noRedirects.PostForm(u, form) |
| 628 | if err != nil { |
| 629 | t.Fatalf("POST %s: %v", u, err) |
| 630 | } |
| 631 | defer resp.Body.Close() |
| 632 | body, _ := io.ReadAll(resp.Body) |
| 633 | return resp.StatusCode, resp.Header.Get("Location"), string(body) |
| 634 | } |
| 635 | |
| 636 | // A tester who is not signed in types the address Checkout would have |
| 637 | // asked for; the sandbox does not make one up. Someone signed in already |
| 638 | // has theirs, and is not asked. |
| 639 | func TestFakeCheckoutAsksAnAnonymousTesterForAnEmail(t *testing.T) { |
| 640 | r := start(t) |
| 641 | ctx := context.Background() |
| 642 | |
| 643 | known := r.checkout(t) |
| 644 | _, page := get(t, known.URL) |
| 645 | if strings.Contains(page, `name="email"`) { |
| 646 | t.Errorf("a signed-in tester is asked for an email:\n%s", page) |
| 647 | } |
| 648 | // Their address is Checkout's, whatever the form carries. |
| 649 | if code, _, _ := postPage(t, known.URL+"/pay", url.Values{"email": {"someone.else@example.com"}}); code != http.StatusSeeOther { |
| 650 | t.Fatalf("Pay as a signed-in tester: status %d", code) |
| 651 | } |
| 652 | if got := r.sub(t, "sub_1").Customer.Email; got != "ana@example.com" { |
| 653 | t.Errorf("a signed-in tester's customer email = %q, want Checkout's own", got) |
| 654 | } |
| 655 | |
| 656 | // Nor is someone whose subject Checkout has, with no address: the |
| 657 | // subject says who they are. |
| 658 | subjectOnly, err := r.client.CreateCheckout(ctx, url.Values{ |
| 659 | "mode": {"subscription"}, "line_items[0][price]": {"price_A"}, "line_items[0][quantity]": {"1"}, |
| 660 | "success_url": {"https://site.example/done"}, "client_reference_id": {"bo"}, |
| 661 | }) |
| 662 | if err != nil { |
| 663 | t.Fatalf("CreateCheckout: %v", err) |
| 664 | } |
| 665 | if _, page := get(t, subjectOnly.URL); strings.Contains(page, `name="email"`) { |
| 666 | t.Errorf("a tester with a subject is asked for an email:\n%s", page) |
| 667 | } |
| 668 | if code, _, body := postPage(t, subjectOnly.URL+"/pay", nil); code != http.StatusSeeOther { |
| 669 | t.Fatalf("Pay with a subject and no address: status %d\n%s", code, body) |
| 670 | } |
| 671 | if got := r.sub(t, "sub_2").Customer.Email; got != "" { |
| 672 | t.Errorf("a subject-only customer's email = %q, want none", got) |
| 673 | } |
| 674 | r.hook.take() |
| 675 | |
| 676 | cs := r.anonymousCheckout(t) |
| 677 | code, page := get(t, cs.URL) |
| 678 | if code != http.StatusOK { |
| 679 | t.Fatalf("checkout page: status %d", code) |
| 680 | } |
| 681 | keysOnly(t, page, "abono.sandbox.pay", "abono.sandbox.email") |
| 682 | for _, want := range []string{`name="email"`, `type="email"`, "required", `autocomplete="email"`} { |
| 683 | if !strings.Contains(page, want) { |
| 684 | t.Errorf("the email field lacks %s:\n%s", want, page) |
| 685 | } |
| 686 | } |
| 687 | if !regexp.MustCompile(`<label[^>]* for="([^"]+)"[^>]*>abono\.sandbox\.email</label>`).MatchString(page) { |
| 688 | t.Errorf("the field has no label saying abono.sandbox.email:\n%s", page) |
| 689 | } |
| 690 | |
| 691 | // Nothing typed, or something that is not an address: the page again, |
| 692 | // 422, and nothing bought. |
| 693 | for _, bad := range []url.Values{nil, {"email": {""}}, {"email": {" "}}, {"email": {"ana"}}, {"email": {"ana@"}}, |
| 694 | {"email": {"Ana <ana@example.com>"}}, {"email": {"a b@example.com"}}} { |
| 695 | code, loc, body := postPage(t, cs.URL+"/pay", bad) |
| 696 | if code != http.StatusUnprocessableEntity || loc != "" { |
| 697 | t.Errorf("Pay with %q: %d to %q, want 422 and no redirect", bad.Get("email"), code, loc) |
| 698 | } |
| 699 | keysOnly(t, body, "abono.sandbox.pay", "abono.sandbox.email") |
| 700 | if !strings.Contains(body, `name="email"`) { |
| 701 | t.Errorf("Pay with %q: the page is not the checkout again:\n%s", bad.Get("email"), body) |
| 702 | } |
| 703 | } |
| 704 | if _, err := r.client.GetSubscription(ctx, "sub_3"); !stripeapi.NotFound(err) { |
| 705 | t.Errorf("a rejected address bought a subscription: %v", err) |
| 706 | } |
| 707 | if got, _ := r.client.GetCheckout(ctx, cs.ID); got.Status != "open" { |
| 708 | t.Errorf("after rejected addresses the session is %q, want open", got.Status) |
| 709 | } |
| 710 | if len(r.hook.take()) != 0 { |
| 711 | t.Error("a rejected address sent events") |
| 712 | } |
| 713 | |
| 714 | if code, _, body := postPage(t, cs.URL+"/pay", url.Values{"email": {" Bo@Example.com "}}); code != http.StatusSeeOther { |
| 715 | t.Fatalf("Pay with an address: status %d\n%s", code, body) |
| 716 | } |
| 717 | sub := r.sub(t, "sub_3") |
| 718 | if sub.Customer.Email != "Bo@Example.com" { |
| 719 | t.Errorf("customer email = %q, want the address typed (trimmed)", sub.Customer.Email) |
| 720 | } |
| 721 | if done, _ := r.client.GetCheckout(ctx, cs.ID); done.CustomerDetails.Email != "Bo@Example.com" { |
| 722 | t.Errorf("the finished session's email = %q", done.CustomerDetails.Email) |
| 723 | } |
| 724 | } |
| 725 | |