package abono_test import ( "context" "encoding/json" "net/http" "net/http/httptest" "net/url" "sync" "testing" "time" "gorm.io/gorm" "amadan.net/rastrillo/abono" "amadan.net/rastrillo/abono/internal/fakestripe" "amadan.net/rastrillo/abono/internal/stripeapi" ) // stripeDouble is fakestripe served for one test, with the helpers these // tests set it up through: subscriptions and Checkout sessions put there // as they are, the prices seedPlans names, and what Checkout and the // Portal were asked for. type stripeDouble struct { *fakestripe.Fake URL string } func newStripeDouble(t *testing.T) *stripeDouble { t.Helper() srv := httptest.NewUnstartedServer(nil) d := &stripeDouble{URL: "http://" + srv.Listener.Addr().String()} // No webhook: these tests deliver the events they mean to. d.Fake = fakestripe.New(fakestripe.Options{PageBase: d.URL, Now: func() time.Time { return t0 }}) srv.Config.Handler = d.Handler() srv.Start() t.Cleanup(srv.Close) // The prices seedPlans names, so Checkout can learn their intervals. d.price("price_A", "year", 6000, "eur") d.price("price_old", "year", 4000, "eur") d.price("price_M", "month", 600, "eur") return d } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } // service is a service over a fresh database, talking to this double. func (d *stripeDouble) service(t *testing.T, now *time.Time, cfg abono.Config) (*abono.Service, *testDB) { t.Helper() cfg.Key = "rk_test_double" cfg.WebhookSecrets = []string{"whsec_double"} cfg.StripeBase = d.URL if cfg.Origin == "" { cfg.Origin = "https://site.example" } return newService(t, now, cfg) } // subSpec is a subscription as a test describes it. type subSpec struct { ID, Status, Price, Interval string Customer, Email, Name string Start, End time.Time Cancel bool Metadata map[string]string } // put stores sp in the double and returns it as the client reads it, its // customer expanded. func (d *stripeDouble) put(t *testing.T, sp subSpec) stripeapi.Subscription { t.Helper() if sp.Interval == "" { sp.Interval = "year" } d.PutSubscription(fakestripe.Subscription{ ID: sp.ID, Customer: sp.Customer, Email: sp.Email, Name: sp.Name, Status: sp.Status, Price: sp.Price, Interval: sp.Interval, PeriodStart: sp.Start, PeriodEnd: sp.End, CancelAtPeriodEnd: sp.Cancel, Metadata: sp.Metadata, }) return d.sub(t, sp.ID) } // sub is subscription id as the client reads it. func (d *stripeDouble) sub(t *testing.T, id string) stripeapi.Subscription { t.Helper() b, ok := d.SubscriptionJSON(id) if !ok { t.Fatalf("no subscription %s in the double", id) } var s stripeapi.Subscription if err := json.Unmarshal(b, &s); err != nil { t.Fatal(err) } return s } // price stores a recurring price. func (d *stripeDouble) price(id, interval string, amount int64, currency string) { d.AddPrice(id, "", amount, currency, interval) } // fetches is how many times each price has been read. func (d *stripeDouble) fetches() map[string]int { return d.PriceFetches() } // session stores a Checkout session. A complete one is paid, an open one // unpaid; paying overrides that. func (d *stripeDouble) session(id, status, subID, ref, email string) { paying := "unpaid" if status == "complete" { paying = "paid" } d.PutSession(fakestripe.Session{ID: id, Status: status, PaymentStatus: paying, Subscription: subID, ClientReferenceID: ref, Email: email}) } // paying sets a session's payment_status. func (d *stripeDouble) paying(id, status string) { s, _ := d.Session(id) s.PaymentStatus = status d.PutSession(s) } func (d *stripeDouble) lastCheckout(t *testing.T) url.Values { t.Helper() forms := d.CheckoutForms() if len(forms) == 0 { t.Fatal("no Checkout session was created") } return forms[len(forms)-1] } // recorder is an app's Holder and OnChange, counting what it was asked. type recorder struct { mu sync.Mutex subject string // what Holder answers asked []string changes []string // pause, when set, runs at the start of every Holder call. pause func() } func (r *recorder) Holder(_ context.Context, _ *gorm.DB, email, _ string) (string, error) { if r.pause != nil { r.pause() } r.mu.Lock() defer r.mu.Unlock() r.asked = append(r.asked, email) return r.subject, nil } func (r *recorder) OnChange(_ context.Context, _ *gorm.DB, subject string) error { r.mu.Lock() defer r.mu.Unlock() r.changes = append(r.changes, subject) return nil } func (r *recorder) config() abono.Config { return abono.Config{Holder: r.Holder, OnChange: r.OnChange} } // take returns what Holder and OnChange were given since the last take. func (r *recorder) take() (asked, changes []string) { r.mu.Lock() defer r.mu.Unlock() asked, changes = r.asked, r.changes r.asked, r.changes = nil, nil return asked, changes }