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 "net/http"
7 "sort"
8 "strings"
9 "sync"
10 "testing"
11
12 "gorm.io/gorm"
13
14 "amadan.net/rastrillo/abono"
15)
16
17func TestAdoptAddsTheLegacyPriceAndSyncs(t *testing.T) {
18 now := t0
19 d := newStripeDouble(t)
20 rec := &recorder{subject: "42"}
21 s, db := d.service(t, &now, rec.config())
22 seedPlans(t, s)
23 ctx := context.Background()
24
25 // A WordPress-era subscription on a price no plan knows.
26 d.put(t, subSpec{ID: "sub_wp", Status: "active", Price: "price_wp", Interval: "year", Customer: "cus_wp", Email: "wp@example.com", End: t0.Add(100 * day)})
27 if err := s.Adopt(ctx, "sub_wp", "3", "annual"); err != nil {
28 t.Fatalf("Adopt: %v", err)
29 }
30 if got := db.dump("SELECT plan, sold, interval FROM abono_prices WHERE stripe_price_id = 'price_wp'"); got != "annual|0|year\n" {
31 t.Errorf("price_wp = %q, want a legacy price of annual", got)
32 }
33 if got, want := db.dump(rowsQuery), "sub_wp|cus_wp|3|annual|price_wp|active|0|wp@example.com\n"; got != want {
34 t.Errorf("rows =\n%s\nwant\n%s", got, want)
35 }
36 if !hasPlan(t, s, "3", "annual") {
37 t.Error("HasPlan(3, annual) is false after Adopt")
38 }
39 asked, changes := rec.take()
40 eq(t, "Holder asked", asked, nil)
41 eq(t, "OnChange", changes, []string{"3"})
42
43 // Adopting it again is a no-op.
44 if err := s.Adopt(ctx, "sub_wp", "3", "annual"); err != nil {
45 t.Fatalf("Adopt again: %v", err)
46 }
47 if _, changes := rec.take(); len(changes) != 0 {
48 t.Errorf("a second Adopt told %q", changes)
49 }
50
51 // A price already on another plan is refused, and nothing is written.
52 d.put(t, subSpec{ID: "sub_m", Status: "active", Price: "price_M", Customer: "cus_m", Email: "m@example.com", End: t0.Add(10 * day)})
53 if err := s.Adopt(ctx, "sub_m", "3", "annual"); err == nil {
54 t.Error("Adopt onto annual of a subscription on monthly's price succeeded")
55 }
56 if got := db.dump("SELECT plan, sold FROM abono_prices WHERE stripe_price_id = 'price_M'"); got != "monthly|1\n" {
57 t.Errorf("price_M = %q, want it left on monthly", got)
58 }
59 if got := db.dump("SELECT * FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_m'"); got != "" {
60 t.Errorf("a refused Adopt wrote a row: %q", got)
61 }
62
63 if err := s.Adopt(ctx, "sub_wp", "3", "nonesuch"); !errors.Is(err, abono.ErrNotFound) {
64 t.Errorf("Adopt onto a plan with no offer: err = %v, want ErrNotFound", err)
65 }
66 if err := s.Adopt(ctx, "sub_gone", "3", "annual"); err == nil {
67 t.Error("Adopt of a subscription Stripe does not have succeeded")
68 }
69}
70
71// TestAdoptWithoutSubjectFindsTheHolder is ruling R7: an unrecognised
72// row usually has no subject, so Adopt finds one as sync does.
73func TestAdoptWithoutSubjectFindsTheHolder(t *testing.T) {
74 now := t0
75 d := newStripeDouble(t)
76 rec := &recorder{subject: "42"}
77 s, db := d.service(t, &now, rec.config())
78 seedPlans(t, s)
79 ctx := context.Background()
80
81 sub := d.put(t, subSpec{ID: "sub_z", Status: "active", Price: "price_Z", Customer: "cus_z", Email: "zed@example.com", End: t0.Add(30 * day)})
82 if err := s.Sync(ctx, sub, abono.Hint{Email: "zed@example.com"}); err != nil {
83 t.Fatalf("Sync: %v", err)
84 }
85 if asked, _ := rec.take(); len(asked) != 0 {
86 t.Fatalf("Holder asked %q for an unrecognised price", asked)
87 }
88
89 if err := s.Adopt(ctx, "sub_z", "", "monthly"); err != nil {
90 t.Fatalf("Adopt with no subject: %v", err)
91 }
92 if got, want := db.dump(rowsQuery), "sub_z|cus_z|42|monthly|price_Z|active|0|zed@example.com\n"; got != want {
93 t.Errorf("rows =\n%s\nwant\n%s", got, want)
94 }
95 asked, changes := rec.take()
96 eq(t, "Holder asked", asked, []string{"zed@example.com"})
97 eq(t, "OnChange", changes, []string{"42"})
98 if un, err := s.Unrecognised(ctx); err != nil || len(un) != 0 {
99 t.Errorf("Unrecognised after Adopt = %+v, %v; want none", un, err)
100 }
101
102 // A customer already mapped decides before Holder is asked.
103 db.exec(`INSERT INTO abono_customers (stripe_customer_id, subject) VALUES ('cus_y', '8')`)
104 d.put(t, subSpec{ID: "sub_y", Status: "active", Price: "price_Y", Customer: "cus_y", Email: "why@example.com", End: t0.Add(30 * day)})
105 if err := s.Adopt(ctx, "sub_y", "", "monthly"); err != nil {
106 t.Fatalf("Adopt of a mapped customer: %v", err)
107 }
108 if got := db.dump("SELECT subject FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_y'"); got != "8\n" {
109 t.Errorf("sub_y subject = %q, want 8", got)
110 }
111 if asked, _ := rec.take(); len(asked) != 0 {
112 t.Errorf("Holder asked %q for a mapped customer", asked)
113 }
114}
115
116// TestAnOwnersAdoptionOutlastsLaterHints is final review I3 (ruling R36):
117// A bought the subscription signed in, so its metadata and its Checkout
118// session both name A. The owner then adopts it for B. Later events, a
119// second visit to Checkout's return and the reconcile all carry A as a
120// hint; none of them gives it back to A. Claim pins a row the same way.
121func TestAnOwnersAdoptionOutlastsLaterHints(t *testing.T) {
122 now := t0
123 d := newStripeDouble(t)
124 rec := &recorder{}
125 s, db := d.service(t, &now, rec.config())
126 seedPlans(t, s)
127 ctx := context.Background()
128
129 d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com",
130 End: t0.Add(30 * day), Metadata: map[string]string{"abono_subject": "A"}})
131 d.session("cs_1", "complete", "sub_1", "A", "ana@example.com")
132 if _, err := s.Finish(ctx, "cs_1"); err != nil {
133 t.Fatalf("Finish: %v", err)
134 }
135 if err := s.Adopt(ctx, "sub_1", "B", "annual"); err != nil {
136 t.Fatalf("Adopt for B: %v", err)
137 }
138 holder := func(when string) {
139 t.Helper()
140 if got := db.dump(`SELECT subject FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_1'`); got != "B\n" {
141 t.Errorf("%s: subject = %q, want B, whom the owner adopted it for", when, got)
142 }
143 if got := db.dump(`SELECT subject FROM abono_customers WHERE stripe_customer_id = 'cus_1'`); got != "B\n" {
144 t.Errorf("%s: cus_1 is mapped to %q, want B", when, got)
145 }
146 }
147 holder("after Adopt")
148
149 wantStatus(t, deliver(t, s, event("evt_1", "customer.subscription.updated", map[string]any{"id": "sub_1"})), http.StatusOK)
150 holder("after a subscription event naming A")
151 wantStatus(t, deliver(t, s, event("evt_2", "checkout.session.completed",
152 map[string]any{"id": "cs_1", "subscription": "sub_1", "client_reference_id": "A"})), http.StatusOK)
153 holder("after a Checkout event naming A")
154 if f, err := s.Finish(ctx, "cs_1"); err != nil || f.Subject != "B" {
155 t.Errorf("Finish again = %+v, %v; want B's", f, err)
156 }
157 holder("after Checkout's return again")
158 if _, err := s.Reconcile(ctx); err != nil {
159 t.Fatalf("Reconcile: %v", err)
160 }
161 holder("after Reconcile")
162 if hasPlan(t, s, "A", "annual") || !hasPlan(t, s, "B", "annual") {
163 t.Error("the plan went back to A")
164 }
165
166 // Claim is an explicit decision too.
167 d.put(t, subSpec{ID: "sub_2", Status: "active", Price: "price_M", Customer: "cus_2", Email: "walkin@example.com", End: t0.Add(30 * day)})
168 if err := s.Sync(ctx, d.sub(t, "sub_2"), abono.Hint{}); err != nil {
169 t.Fatalf("Sync: %v", err)
170 }
171 if n, err := s.Claim(ctx, "C", "walkin@example.com"); err != nil || n != 1 {
172 t.Fatalf("Claim = %d, %v", n, err)
173 }
174 if err := s.Sync(ctx, d.sub(t, "sub_2"), abono.Hint{Subject: "D"}); err != nil {
175 t.Fatalf("Sync with D's hint: %v", err)
176 }
177 if got := db.dump(`SELECT subject FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_2'`); got != "C\n" {
178 t.Errorf("a claimed subscription went to %q on a later hint, want C", got)
179 }
180}
181
182// TestAdoptBringsEveryoneOnThePrice is final review I11 (ruling R40): the
183// page promises that adopting one subscription puts everyone paying that
184// price onto the plan. Two holders are recorded on a price no plan has;
185// adopting one makes both members at once, each told through OnChange,
186// before Adopt returns.
187func TestAdoptBringsEveryoneOnThePrice(t *testing.T) {
188 now := t0
189 d := newStripeDouble(t)
190 var mu sync.Mutex
191 var told []string
192 s, db := d.service(t, &now, abono.Config{
193 Holder: func(_ context.Context, _ *gorm.DB, email, _ string) (string, error) {
194 return strings.TrimSuffix(email, "@example.com"), nil
195 },
196 OnChange: func(_ context.Context, _ *gorm.DB, subject string) error {
197 mu.Lock()
198 defer mu.Unlock()
199 told = append(told, subject)
200 return nil
201 },
202 })
203 seedPlans(t, s)
204 ctx := context.Background()
205 for _, sp := range []subSpec{
206 {ID: "sub_z1", Status: "active", Price: "price_Z", Customer: "cus_1", Email: "one@example.com", End: t0.Add(30 * day)},
207 {ID: "sub_z2", Status: "active", Price: "price_Z", Customer: "cus_2", Email: "two@example.com", End: t0.Add(30 * day)},
208 } {
209 if err := s.Sync(ctx, d.put(t, sp), abono.Hint{}); err != nil {
210 t.Fatalf("Sync %s: %v", sp.ID, err)
211 }
212 }
213 if un, err := s.Unrecognised(ctx); err != nil || len(un) != 2 {
214 t.Fatalf("Unrecognised = %+v, %v; want both", un, err)
215 }
216
217 if err := s.Adopt(ctx, "sub_z1", "", "monthly"); err != nil {
218 t.Fatalf("Adopt: %v", err)
219 }
220 if got := db.dump(`SELECT stripe_subscription_id, subject, plan FROM abono_subscriptions ORDER BY stripe_subscription_id`); got != "sub_z1|one|monthly\nsub_z2|two|monthly\n" {
221 t.Errorf("rows after Adopt =\n%s\nwant both on monthly, each with its holder", got)
222 }
223 for _, who := range []string{"one", "two"} {
224 if !hasPlan(t, s, who, "monthly") {
225 t.Errorf("%s does not hold monthly after the adoption", who)
226 }
227 }
228 mu.Lock()
229 sort.Strings(told)
230 got := strings.Join(told, ",")
231 mu.Unlock()
232 if got != "one,two" {
233 t.Errorf("OnChange told %q, want one and two", got)
234 }
235 if un, err := s.Unrecognised(ctx); err != nil || len(un) != 0 {
236 t.Errorf("Unrecognised after Adopt = %+v, %v; want none", un, err)
237 }
238}
239