| 1 | package abono_test |
| 2 | |
| 3 | import ( |
| 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. |
| 24 | type stripeDouble struct { |
| 25 | *fakestripe.Fake |
| 26 | URL string |
| 27 | } |
| 28 | |
| 29 | func 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 | |
| 45 | func 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. |
| 52 | func (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. |
| 64 | type 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. |
| 74 | func (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. |
| 87 | func (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. |
| 101 | func (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. |
| 106 | func (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. |
| 110 | func (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. |
| 120 | func (d *stripeDouble) paying(id, status string) { |
| 121 | s, _ := d.Session(id) |
| 122 | s.PaymentStatus = status |
| 123 | d.PutSession(s) |
| 124 | } |
| 125 | |
| 126 | func (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. |
| 136 | type 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 | |
| 146 | func (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 | |
| 156 | func (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 | |
| 163 | func (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. |
| 168 | func (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 | |