rastrillo / abono Public

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

Plain git — no account needed to clone.

Download

Download this file

1package abono_test
2
3import (
4 "context"
5 "errors"
6 "strings"
7 "testing"
8 "time"
9
10 "gorm.io/gorm"
11
12 "amadan.net/rastrillo/abono"
13)
14
15func TestReconcileEndsAnExpiredCompThroughOnChange(t *testing.T) {
16 now := t0
17 d := newStripeDouble(t)
18 rec := &recorder{}
19 s, _ := d.service(t, &now, rec.config())
20 seedPlans(t, s)
21 ctx := context.Background()
22
23 ends := t0.Add(2 * time.Hour)
24 if _, err := s.Comp(ctx, "ana", "annual", &ends, "press"); err != nil {
25 t.Fatalf("Comp: %v", err)
26 }
27 rec.take()
28 if !hasPlan(t, s, "ana", "annual") {
29 t.Fatal("the comp does not grant before it runs out")
30 }
31
32 now = t0.Add(3 * time.Hour)
33 rep, err := s.Reconcile(ctx)
34 if err != nil {
35 t.Fatalf("Reconcile: %v", err)
36 }
37 if rep.Subjects != 1 {
38 t.Errorf("Report = %+v, want 1 subject", rep)
39 }
40 if _, changes := rec.take(); len(changes) != 1 || changes[0] != "ana" {
41 t.Errorf("OnChange ran %v, want once for ana", changes)
42 }
43 if hasPlan(t, s, "ana", "annual") {
44 t.Error("the expired comp still grants")
45 }
46}
47
48func TestReconcileTellsEverySubjectHoldingAnything(t *testing.T) {
49 now := t0
50 d := newStripeDouble(t)
51 rec := &recorder{}
52 s, _ := d.service(t, &now, rec.config())
53 seedPlans(t, s)
54 ctx := context.Background()
55
56 if _, err := s.Comp(ctx, "cy", "annual", nil, ""); err != nil {
57 t.Fatalf("Comp: %v", err)
58 }
59 // ana and bo hold subscriptions (one on a price nobody sells); a third
60 // is unclaimed and has nobody to tell.
61 d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day),
62 Metadata: map[string]string{"abono_subject": "ana"}})
63 d.put(t, subSpec{ID: "sub_2", Status: "active", Price: "price_Z", Customer: "cus_2", Email: "bo@example.com", End: t0.Add(30 * day)})
64 d.put(t, subSpec{ID: "sub_3", Status: "active", Price: "price_M", Customer: "cus_3", Email: "di@example.com", End: t0.Add(30 * day)})
65 for _, id := range []string{"sub_1", "sub_2"} {
66 if err := s.Sync(ctx, d.sub(t, id), abono.Hint{Subject: map[string]string{"sub_1": "ana", "sub_2": "bo"}[id]}); err != nil {
67 t.Fatalf("Sync %s: %v", id, err)
68 }
69 }
70 rec.take()
71
72 rep, err := s.Reconcile(ctx)
73 if err != nil {
74 t.Fatalf("Reconcile: %v", err)
75 }
76 if rep.Subscriptions != 3 || rep.Subjects != 3 {
77 t.Errorf("Report = %+v, want 3 subscriptions and 3 subjects", rep)
78 }
79 _, changes := rec.take()
80 got := map[string]int{}
81 for _, c := range changes {
82 got[c]++
83 }
84 // sub_3's sync makes its row (unclaimed: Holder answers ""), and ana, bo
85 // and cy are each told once by the sweep.
86 for _, who := range []string{"ana", "bo", "cy"} {
87 if got[who] != 1 {
88 t.Errorf("%s was told %d times, want 1 (all: %v)", who, got[who], changes)
89 }
90 }
91 if got[""] != 0 || len(got) != 3 {
92 t.Errorf("OnChange ran for %v", changes)
93 }
94}
95
96func TestReconcileRefreshesPrices(t *testing.T) {
97 now := t0
98 d := newStripeDouble(t)
99 s, db := d.service(t, &now, abono.Config{})
100 seedPlans(t, s)
101 const q = `SELECT stripe_price_id, interval, amount, currency FROM abono_prices ORDER BY stripe_price_id`
102
103 // Stripe's prices change under the account (a new currency, say).
104 d.price("price_A", "year", 7200, "usd")
105 d.price("price_M", "month", 700, "usd")
106 d.price("price_old", "year", 4500, "usd")
107
108 rep, err := s.Reconcile(context.Background())
109 if err != nil {
110 t.Fatalf("Reconcile: %v", err)
111 }
112 if rep.Subscriptions != 0 || rep.Subjects != 0 {
113 t.Errorf("Report = %+v, want nothing", rep)
114 }
115 want := "price_A|year|7200|usd\nprice_M|month|700|usd\nprice_old|year|4500|usd\n"
116 if got := db.dump(q); got != want {
117 t.Errorf("prices:\n%s\nwant:\n%s", got, want)
118 }
119}
120
121func TestReconcileRecoversALostSubscription(t *testing.T) {
122 now := t0
123 d := newStripeDouble(t)
124 rec := &recorder{subject: "ana"}
125 s, db := d.service(t, &now, rec.config())
126 seedPlans(t, s)
127 d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day)})
128 d.put(t, subSpec{ID: "sub_0", Status: "canceled", Price: "price_A", Customer: "cus_0", Email: "old@example.com", End: t0.Add(-day)})
129
130 rep, err := s.Reconcile(context.Background())
131 if err != nil {
132 t.Fatalf("Reconcile: %v", err)
133 }
134 if rep.Subscriptions != 2 || rep.Subjects != 1 {
135 t.Errorf("Report = %+v, want 2 subscriptions and 1 subject", rep)
136 }
137 if got := db.dump(subsQuery); got != "sub_1|ana|annual|active|ana@example.com\n" {
138 t.Errorf("subscriptions:\n%s", got)
139 }
140}
141
142func TestReconcileWithStripeOffStillSweepsComps(t *testing.T) {
143 now := t0
144 rec := &recorder{}
145 s, _ := newService(t, &now, rec.config())
146 if _, err := s.Comp(context.Background(), "ana", "annual", nil, ""); err != nil {
147 t.Fatalf("Comp: %v", err)
148 }
149 rec.take()
150 rep, err := s.Reconcile(context.Background())
151 if err != nil {
152 t.Fatalf("Reconcile: %v", err)
153 }
154 if rep.Subjects != 1 {
155 t.Errorf("Report = %+v", rep)
156 }
157 if _, changes := rec.take(); len(changes) != 1 {
158 t.Errorf("OnChange ran %v", changes)
159 }
160}
161
162// One subscription the app cannot take (Holder fails for it) does not stop
163// the ones listed after it, and is named in the error.
164func TestReconcileCarriesOnPastOneBadSubscription(t *testing.T) {
165 now := t0
166 d := newStripeDouble(t)
167 s, db := d.service(t, &now, abono.Config{
168 Holder: func(_ context.Context, _ *gorm.DB, email, _ string) (string, error) {
169 if email == "bad@example.com" {
170 return "", errors.New("the app refuses " + email)
171 }
172 return "bo", nil
173 },
174 })
175 seedPlans(t, s)
176 // Listed in id order: the failing one first.
177 d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "bad@example.com", End: t0.Add(30 * day)})
178 d.put(t, subSpec{ID: "sub_2", Status: "active", Price: "price_M", Customer: "cus_2", Email: "bo@example.com", End: t0.Add(30 * day)})
179 d.price("price_M", "month", 700, "usd")
180
181 rep, err := s.Reconcile(context.Background())
182 if err == nil || !strings.Contains(err.Error(), "sub_1") {
183 t.Fatalf("Reconcile error = %v, want one naming sub_1", err)
184 }
185 if strings.Contains(err.Error(), "sub_2") {
186 t.Errorf("the error blames the good subscription: %v", err)
187 }
188 if rep.Subscriptions != 2 || rep.Failed != 1 || rep.Subjects != 1 {
189 t.Errorf("Report = %+v, want 2 subscriptions seen, 1 failed and 1 subject told", rep)
190 }
191 if got := db.dump(subsQuery); got != "sub_2|bo|monthly|active|bo@example.com\n" {
192 t.Errorf("subscriptions:\n%s", got)
193 }
194 // The later steps ran too.
195 if got := db.dump(`SELECT amount FROM abono_prices WHERE stripe_price_id = 'price_M'`); got != "700\n" {
196 t.Errorf("price_M amount = %q", got)
197 }
198}
199
200// TestReconcileRestoresTheSignedInBuyer is final review I1 (ruling R34):
201// subject A, signed in, pays with an address that belongs to B, and both
202// Checkout's return and the webhooks are lost. The reconcile that recovers
203// the subscription reads Checkout's abono_subject metadata, so it is A's,
204// not B's by address, and not left unclaimed.
205func TestReconcileRestoresTheSignedInBuyer(t *testing.T) {
206 now := t0
207 d := newStripeDouble(t)
208 rec := &recorder{subject: "B"} // the app knows the address as B's
209 s, db := d.service(t, &now, rec.config())
210 seedPlans(t, s)
211 d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "b@example.com",
212 End: t0.Add(30 * day), Metadata: map[string]string{"abono_subject": "A", "abono_plan": "annual"}})
213
214 if _, err := s.Reconcile(context.Background()); err != nil {
215 t.Fatalf("Reconcile: %v", err)
216 }
217 if got := db.dump(subsQuery); got != "sub_1|A|annual|active|b@example.com\n" {
218 t.Errorf("subscriptions:\n%s\nwant sub_1 held by A, who bought it", got)
219 }
220 if got := db.dump(`SELECT stripe_customer_id, subject FROM abono_customers`); got != "cus_1|A\n" {
221 t.Errorf("customers = %q, want cus_1 mapped to A", got)
222 }
223 if !hasPlan(t, s, "A", "annual") || hasPlan(t, s, "B", "annual") {
224 t.Error("the plan went to the address's owner, not the buyer")
225 }
226}
227