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 "reflect"
7 "strings"
8 "testing"
9
10 "amadan.net/rastrillo/abono"
11)
12
13func TestSeedPlansIsIdempotent(t *testing.T) {
14 now := t0
15 s, db := newService(t, &now, abono.Config{})
16 ctx := context.Background()
17
18 spec := []abono.PlanSpec{
19 {Plan: "annual", Sold: "price_A", Legacy: []string{"price_old"}},
20 {Plan: "monthly", Sold: "price_B"},
21 }
22 if err := s.SeedPlans(ctx, spec); err != nil {
23 t.Fatalf("SeedPlans: %v", err)
24 }
25 first := db.dump("SELECT plan, name FROM abono_offers ORDER BY plan") +
26 db.dump("SELECT stripe_price_id, plan, sold FROM abono_prices ORDER BY stripe_price_id")
27 if err := s.SeedPlans(ctx, spec); err != nil {
28 t.Fatalf("second SeedPlans: %v", err)
29 }
30 second := db.dump("SELECT plan, name FROM abono_offers ORDER BY plan") +
31 db.dump("SELECT stripe_price_id, plan, sold FROM abono_prices ORDER BY stripe_price_id")
32 if first != second {
33 t.Errorf("a second SeedPlans changed the rows:\nfirst:\n%s\nsecond:\n%s", first, second)
34 }
35 want := "annual|\nmonthly|\n" + "price_A|annual|1\nprice_B|monthly|1\nprice_old|annual|0\n"
36 if first != want {
37 t.Errorf("rows =\n%s\nwant\n%s", first, want)
38 }
39
40 // A price removed from the spec stays: existing subscriptions on it
41 // must still resolve to their plan.
42 if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_A"}, {Plan: "monthly", Sold: "price_B"}}); err != nil {
43 t.Fatalf("SeedPlans without the legacy price: %v", err)
44 }
45 if got := db.dump("SELECT stripe_price_id FROM abono_prices WHERE stripe_price_id = 'price_old'"); got != "price_old\n" {
46 t.Errorf("price_old was dropped from the table: %q", got)
47 }
48
49 // A new sold price takes over from the old one named in the spec.
50 if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_C", Legacy: []string{"price_A"}}}); err != nil {
51 t.Fatalf("SeedPlans with a new sold price: %v", err)
52 }
53 if got := db.dump("SELECT stripe_price_id, sold FROM abono_prices WHERE plan = 'annual' ORDER BY stripe_price_id"); got != "price_A|0\nprice_C|1\nprice_old|0\n" {
54 t.Errorf("annual prices = %q", got)
55 }
56}
57
58func TestSeedPlansRefusesBadSpecs(t *testing.T) {
59 now := t0
60 s, db := newService(t, &now, abono.Config{})
61 for name, spec := range map[string][]abono.PlanSpec{
62 "no plan name": {{Sold: "price_A"}},
63 "no sold price": {{Plan: "annual"}},
64 "a blank legacy price": {{Plan: "annual", Sold: "price_A", Legacy: []string{""}}},
65 "a price in two plans": {{Plan: "annual", Sold: "price_A"}, {Plan: "monthly", Sold: "price_B", Legacy: []string{"price_A"}}},
66 "a plan twice": {{Plan: "annual", Sold: "price_A"}, {Plan: "annual", Sold: "price_B"}},
67 "a price twice in a plan": {{Plan: "annual", Sold: "price_A", Legacy: []string{"price_A"}}},
68 } {
69 if err := s.SeedPlans(context.Background(), spec); err == nil {
70 t.Errorf("%s: SeedPlans succeeded, want an error", name)
71 }
72 }
73 if got := db.dump("SELECT plan FROM abono_offers"); got != "" {
74 t.Errorf("a refused spec left offers behind: %q", got)
75 }
76}
77
78func TestOffersAndAddPrice(t *testing.T) {
79 now := t0
80 s, db := newService(t, &now, abono.Config{})
81 ctx := context.Background()
82
83 if err := s.SeedPlans(ctx, []abono.PlanSpec{
84 {Plan: "annual", Sold: "price_A", Legacy: []string{"price_old"}},
85 {Plan: "monthly", Sold: "price_B"},
86 }); err != nil {
87 t.Fatal(err)
88 }
89 // What a later refresh from Stripe would record.
90 db.exec("UPDATE abono_prices SET interval = 'year', amount = 4900, currency = 'eur' WHERE stripe_price_id = 'price_A'")
91 db.exec("UPDATE abono_offers SET name = 'Annual membership' WHERE plan = 'annual'")
92
93 offers, err := s.Offers(ctx)
94 if err != nil {
95 t.Fatalf("Offers: %v", err)
96 }
97 if len(offers) != 2 || offers[0].Plan != "annual" || offers[1].Plan != "monthly" {
98 t.Fatalf("offers = %+v", offers)
99 }
100 if offers[0].Name != "Annual membership" {
101 t.Errorf("annual Name = %q", offers[0].Name)
102 }
103 if offers[1].Name != "monthly" {
104 t.Errorf("monthly Name = %q, want it to default to the plan", offers[1].Name)
105 }
106 annual := offers[0].Prices
107 if len(annual) != 2 || annual[0] != (abono.Price{ID: "price_A", Sold: true, Interval: "year", Amount: 4900, Currency: "eur"}) ||
108 annual[1].ID != "price_old" || annual[1].Sold {
109 t.Errorf("annual prices = %+v, want the sold price first", annual)
110 }
111
112 // Selling a price reads it from Stripe first (ruling R38), so with
113 // Stripe off only a legacy price can be added.
114 if err := s.AddPrice(ctx, "monthly", "price_B2", true); err == nil {
115 t.Fatal("AddPrice of a sold price succeeded with Stripe off")
116 }
117 if err := s.AddPrice(ctx, "monthly", "price_B2", false); err != nil {
118 t.Fatalf("AddPrice: %v", err)
119 }
120 if err := s.AddPrice(ctx, "nope", "price_X", false); err == nil {
121 t.Error("AddPrice to an unknown plan succeeded")
122 }
123 if err := s.AddPrice(ctx, "annual", "price_B", false); err == nil {
124 t.Error("AddPrice moved a price that belongs to another plan")
125 }
126 offers, _ = s.Offers(ctx)
127 if got := offers[1].Prices; len(got) != 2 || got[0].ID != "price_B" || got[1].ID != "price_B2" || got[1].Sold {
128 t.Errorf("monthly prices = %+v", got)
129 }
130
131 // An archived offer is not on offer.
132 db.exec("UPDATE abono_offers SET archived_at = '2026-10-01 00:00:00' WHERE plan = 'monthly'")
133 offers, _ = s.Offers(ctx)
134 if len(offers) != 1 || offers[0].Plan != "annual" {
135 t.Errorf("offers after archiving = %+v", offers)
136 }
137}
138
139func TestSeedPlansDemotesTheOldSoldPrice(t *testing.T) {
140 now := t0
141 s, db := newService(t, &now, abono.Config{})
142 ctx := context.Background()
143
144 if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_A"}, {Plan: "monthly", Sold: "price_M"}}); err != nil {
145 t.Fatal(err)
146 }
147 if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_B", Legacy: []string{"price_A"}}}); err != nil {
148 t.Fatal(err)
149 }
150 got := db.dump("SELECT stripe_price_id, plan, sold FROM abono_prices ORDER BY stripe_price_id")
151 if want := "price_A|annual|0\nprice_B|annual|1\nprice_M|monthly|1\n"; got != want {
152 t.Errorf("prices =\n%s\nwant\n%s", got, want)
153 }
154
155 // A price the spec no longer names is demoted too, and still not deleted.
156 if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_C"}}); err != nil {
157 t.Fatal(err)
158 }
159 got = db.dump("SELECT stripe_price_id, sold FROM abono_prices WHERE plan = 'annual' ORDER BY stripe_price_id")
160 if want := "price_A|0\nprice_B|0\nprice_C|1\n"; got != want {
161 t.Errorf("annual prices =\n%s\nwant\n%s", got, want)
162 }
163}
164
165// TestAnUnsupportedPriceIsNeverSold is final review I7 (ruling R39): a
166// price of €120 every twelve months has Stripe's interval "month". abono
167// keeps its interval_count, never sells it, and says so on Offers.
168func TestAnUnsupportedPriceIsNeverSold(t *testing.T) {
169 now := t0
170 d := newStripeDouble(t)
171 s, db := d.service(t, &now, abono.Config{})
172 ctx := context.Background()
173 d.price("price_12m", "month", 12000, "eur")
174 d.SetIntervalCount("price_12m", 12)
175 if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "dozen", Sold: "price_12m"}}); err != nil {
176 t.Fatalf("SeedPlans: %v", err)
177 }
178 for _, iv := range []string{"month", ""} {
179 _, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "dozen", Interval: iv, SuccessPath: "/done"})
180 if !errors.Is(err, abono.ErrUnknownPlan) {
181 t.Errorf("Checkout(dozen, %q): err = %v, want ErrUnknownPlan", iv, err)
182 }
183 }
184 if len(d.CheckoutForms()) != 0 {
185 t.Error("a Checkout session was made for an unsupported price")
186 }
187 if got := db.dump(`SELECT interval, interval_count FROM abono_prices WHERE stripe_price_id = 'price_12m'`); got != "month|12\n" {
188 t.Errorf("price_12m = %q, want month|12", got)
189 }
190 offers, err := s.Offers(ctx)
191 if err != nil || len(offers) != 1 || len(offers[0].Prices) != 1 {
192 t.Fatalf("Offers = %+v, %v", offers, err)
193 }
194 if p := offers[0].Prices[0]; p.IntervalCount != 12 || !p.Unsupported() {
195 t.Errorf("Offers price = %+v, want every 12 months and unsupported", p)
196 }
197}
198
199// TestAddPriceKeepsOneSoldPricePerInterval is final review I6 (ruling
200// R38): adding a replacement sold price for a plan and interval demotes
201// the one it replaces, in the same transaction, so Checkout still finds
202// exactly one to sell. A sold price at another interval stays sold.
203func TestAddPriceKeepsOneSoldPricePerInterval(t *testing.T) {
204 now := t0
205 d := newStripeDouble(t)
206 s, db := d.service(t, &now, abono.Config{})
207 seedPlans(t, s) // annual: price_A (year), legacy price_old; monthly: price_M
208 ctx := context.Background()
209 sold := func() string {
210 t.Helper()
211 return db.dump(`SELECT stripe_price_id FROM abono_prices WHERE plan = 'annual' AND sold = 1 ORDER BY stripe_price_id`)
212 }
213
214 d.price("price_A2", "year", 7000, "eur")
215 if err := s.AddPrice(ctx, "annual", "price_A2", true); err != nil {
216 t.Fatalf("AddPrice: %v", err)
217 }
218 if got := sold(); got != "price_A2\n" {
219 t.Errorf("sold annual prices = %q, want only the replacement", got)
220 }
221 if got := db.dump(`SELECT interval, interval_count, amount, currency, refreshed_at IS NOT NULL FROM abono_prices WHERE stripe_price_id = 'price_A2'`); got != "year|1|7000|eur|1\n" {
222 t.Errorf("price_A2 as recorded = %q, want what Stripe said", got)
223 }
224 if _, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "annual", Interval: "year", SuccessPath: "/done"}); err != nil {
225 t.Fatalf("Checkout at the replaced interval: %v", err)
226 }
227 if got := d.lastCheckout(t).Get("line_items[0][price]"); got != "price_A2" {
228 t.Errorf("Checkout sold %q, want price_A2", got)
229 }
230
231 // Another interval is another sold price, not a replacement.
232 d.price("price_A_month", "month", 700, "eur")
233 if err := s.AddPrice(ctx, "annual", "price_A_month", true); err != nil {
234 t.Fatalf("AddPrice monthly: %v", err)
235 }
236 if got := sold(); got != "price_A2\nprice_A_month\n" {
237 t.Errorf("sold annual prices = %q, want the yearly and the monthly", got)
238 }
239
240 // A price Stripe does not have changes nothing.
241 if err := s.AddPrice(ctx, "annual", "price_nowhere", true); err == nil {
242 t.Error("AddPrice of a price Stripe does not have succeeded")
243 }
244 if got := sold(); got != "price_A2\nprice_A_month\n" {
245 t.Errorf("after a failed AddPrice, sold annual prices = %q", got)
246 }
247 if got := db.dump(`SELECT count(*) FROM abono_prices WHERE stripe_price_id = 'price_nowhere'`); got != "0\n" {
248 t.Error("a price Stripe does not have was recorded")
249 }
250}
251
252// TestOffersReadsPricesNeverRead is final review I12 (ruling R42): a
253// freshly seeded site has never read its prices from Stripe, and nothing
254// in the documented wiring makes it reconcile first. Offers reads, once
255// and best-effort, every price never read, so the pricing UI knows each
256// interval before anyone buys. A price Stripe cannot read is left unknown
257// and does not fail the page.
258func TestOffersReadsPricesNeverRead(t *testing.T) {
259 now := t0
260 d := newStripeDouble(t)
261 s, db := d.service(t, &now, abono.Config{})
262 seedPlans(t, s) // annual: price_A (year) sold, price_old legacy; monthly: price_M
263 ctx := context.Background()
264 // annual is sold monthly too, recorded by id without a read, and a
265 // legacy price Stripe no longer has.
266 d.price("price_A_month", "month", 600, "eur")
267 db.exec(`INSERT INTO abono_prices (stripe_price_id, plan, sold) VALUES ('price_A_month', 'annual', 1), ('price_lost', 'annual', 0)`)
268
269 offers, err := s.Offers(ctx)
270 if err != nil {
271 t.Fatalf("Offers: %v", err)
272 }
273 intervals := map[string]string{}
274 for _, o := range offers {
275 for _, p := range o.Prices {
276 intervals[p.ID] = p.Interval
277 }
278 }
279 if want := map[string]string{"price_A": "year", "price_A_month": "month", "price_old": "year", "price_M": "month", "price_lost": ""}; !reflect.DeepEqual(intervals, want) {
280 t.Errorf("intervals = %v, want %v", intervals, want)
281 }
282 l := testLocalizer(t, testCatalog)
283 picker := render(t, "abono-plan-picker", map[string]any{"L": l, "Offers": offers, "Action": "/subscribe"})
284 for _, want := range []string{"T-MONTHLY", "T-YEARLY", `value="month"`, `value="year"`} {
285 if !strings.Contains(picker, want) {
286 t.Errorf("the picker on a fresh site lacks %q:\n%s", want, picker)
287 }
288 }
289
290 // Read once: a second Offers asks Stripe nothing more (the lost
291 // price aside, which is tried again).
292 before := d.fetches()
293 if _, err := s.Offers(ctx); err != nil {
294 t.Fatalf("Offers again: %v", err)
295 }
296 after := d.fetches()
297 for id, n := range after {
298 if id != "price_lost" && n != before[id] {
299 t.Errorf("%s read again: %d times, was %d", id, n, before[id])
300 }
301 }
302
303 // With Stripe off nothing is read, and Offers still answers.
304 off, _ := newService(t, &now, abono.Config{})
305 if err := off.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_A"}}); err != nil {
306 t.Fatal(err)
307 }
308 if offers, err := off.Offers(ctx); err != nil || len(offers) != 1 || offers[0].Prices[0].Interval != "" {
309 t.Errorf("Offers with Stripe off = %+v, %v", offers, err)
310 }
311}
312