rastrillo / abono Public

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

Plain git — no account needed to clone.

Download

Download this file

1package fakestripe_test
2
3import (
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.
23var t0 = time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)
24
25const secret = "whsec_fake"
26
27// delivery is one event the fake POSTed.
28type delivery struct {
29 signature string
30 body []byte
31}
32
33// hook is a webhook endpoint that keeps what it is sent.
34type hook struct {
35 mu sync.Mutex
36 got []delivery
37 code int // what it answers; 0 is 200
38}
39
40func (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.
51func (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.
61func 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
82type 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.
91func 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.
112var noRedirects = &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
113
114func 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
125func 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).
138func 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.
153func (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.
172func (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
186func (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
195func 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
266func 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
291func 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.
343func 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
374func 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
425func 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
480func 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.
514func 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.
575func 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.
609func (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.
625func 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.
639func 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