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 "bytes"
5 "context"
6 "fmt"
7 "log/slog"
8 "strings"
9 "testing"
10 "time"
11
12 "amadan.net/rastrillo/rastrillo/db"
13 "amadan.net/rastrillo/rastrillo/migrate"
14 "amadan.net/rastrillo/rastrillo/sessions"
15
16 "amadan.net/rastrillo/abono"
17)
18
19// t0 is the fixed "now" the service tests run at.
20var t0 = time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)
21
22// newService is a service over a fresh database with abono's schema
23// applied, its clock fixed at *now. cfg's DB and Now are filled in.
24func newService(t *testing.T, now *time.Time, cfg abono.Config) (*abono.Service, *testDB) {
25 t.Helper()
26 d := openDB(t)
27 if _, err := migrate.Apply(context.Background(), d,
28 migrate.Merge(sessions.Schema, abono.Schema)); err != nil {
29 t.Fatalf("migrate.Apply: %v", err)
30 }
31 cfg.DB = d.G
32 if cfg.Logger == nil {
33 // Quiet: a test that reads the log passes its own.
34 cfg.Logger = slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
35 }
36 cfg.Now = func() time.Time { return *now }
37 s, err := abono.New(cfg)
38 if err != nil {
39 t.Fatalf("abono.New: %v", err)
40 }
41 return s, &testDB{t: t, d: d}
42}
43
44// testDB is a database with a few raw helpers, for setting up rows the
45// service has no method to write and reading back what it wrote.
46type testDB struct {
47 t *testing.T
48 d *db.DB
49}
50
51func (x *testDB) exec(query string, args ...any) {
52 x.t.Helper()
53 if _, err := x.d.Writer().Exec(query, args...); err != nil {
54 x.t.Fatalf("%s: %v", query, err)
55 }
56}
57
58// dump runs query and returns each row as its columns joined by "|"
59// (NULL as ""), one row per line.
60func (x *testDB) dump(query string, args ...any) string {
61 x.t.Helper()
62 rows, err := x.d.Writer().Query(query, args...)
63 if err != nil {
64 x.t.Fatalf("%s: %v", query, err)
65 }
66 defer rows.Close()
67 cols, _ := rows.Columns()
68 var out strings.Builder
69 for rows.Next() {
70 vals := make([]any, len(cols))
71 ptrs := make([]any, len(cols))
72 for i := range vals {
73 ptrs[i] = &vals[i]
74 }
75 if err := rows.Scan(ptrs...); err != nil {
76 x.t.Fatalf("%s: %v", query, err)
77 }
78 for i, v := range vals {
79 if i > 0 {
80 out.WriteString("|")
81 }
82 if v != nil {
83 fmt.Fprint(&out, v)
84 }
85 }
86 out.WriteString("\n")
87 }
88 if err := rows.Err(); err != nil {
89 x.t.Fatalf("%s: %v", query, err)
90 }
91 return out.String()
92}
93
94func TestNewRefusesHalfAConfiguration(t *testing.T) {
95 d := openDB(t)
96
97 for name, cfg := range map[string]abono.Config{
98 "a key without secrets": {DB: d.G, Key: "rk_test_1"},
99 "secrets without a key": {DB: d.G, WebhookSecrets: []string{"whsec_1"}},
100 "a blank secret": {DB: d.G, Key: "rk_test_1", WebhookSecrets: []string{""}},
101 "no database": {},
102 } {
103 if s, err := abono.New(cfg); err == nil {
104 t.Errorf("%s: New succeeded (On=%v), want an error", name, s.On())
105 }
106 }
107
108 s, err := abono.New(abono.Config{DB: d.G})
109 if err != nil {
110 t.Fatalf("New with nothing configured: %v", err)
111 }
112 if s.On() {
113 t.Error("On() with nothing configured, want false")
114 }
115
116 s, err = abono.New(abono.Config{DB: d.G, Key: "rk_test_1", WebhookSecrets: []string{"whsec_1", "whsec_2"}})
117 if err != nil {
118 t.Fatalf("New with a key and secrets: %v", err)
119 }
120 if !s.On() {
121 t.Error("On() with a key and secrets, want true")
122 }
123
124 var nilService *abono.Service
125 if nilService.On() {
126 t.Error("a nil *Service reports On()")
127 }
128}
129
130func TestNewRefusesSandboxOffLoopback(t *testing.T) {
131 d := openDB(t)
132
133 for _, origin := range []string{"https://example.com", "http://192.168.1.5:8080", "http://localhost.example.com", ""} {
134 if _, err := abono.New(abono.Config{DB: d.G, Sandbox: true, Origin: origin}); err == nil {
135 t.Errorf("Sandbox with Origin %q succeeded, want an error", origin)
136 }
137 }
138 for _, origin := range []string{"http://localhost:8080", "http://app.localhost", "http://127.0.0.1:3000", "http://[::1]:3000"} {
139 s, err := abono.New(abono.Config{DB: d.G, Sandbox: true, Origin: origin})
140 if err != nil {
141 t.Errorf("Sandbox with Origin %q: %v", origin, err)
142 continue
143 }
144 if !s.On() {
145 t.Errorf("Sandbox with Origin %q: On() is false, want the sandbox's fake Stripe", origin)
146 }
147 }
148 if _, err := abono.New(abono.Config{DB: d.G, Sandbox: true, Origin: "http://localhost:8080", Key: "rk_test_1"}); err == nil {
149 t.Error("Sandbox with a Key succeeded, want an error")
150 }
151}
152
153func TestParsePlans(t *testing.T) {
154 plans, err := abono.ParsePlans("annual=price_A|price_old1|price_old2, monthly=price_B")
155 if err != nil {
156 t.Fatalf("ParsePlans: %v", err)
157 }
158 if len(plans) != 2 ||
159 plans[0].Plan != "annual" || plans[0].Sold != "price_A" ||
160 strings.Join(plans[0].Legacy, ",") != "price_old1,price_old2" ||
161 plans[1].Plan != "monthly" || plans[1].Sold != "price_B" || len(plans[1].Legacy) != 0 {
162 t.Fatalf("plans = %+v", plans)
163 }
164
165 if plans, err := abono.ParsePlans(""); err != nil || len(plans) != 0 {
166 t.Errorf("empty: %+v %v", plans, err)
167 }
168 if plans, err := abono.ParsePlans("a=p1\nb=p2"); err != nil || len(plans) != 2 {
169 t.Errorf("newline separated: %+v %v", plans, err)
170 }
171
172 for _, bad := range []string{
173 "annual", "Annual=price_A", "annual=", "annual=price_A||price_B", "a b=price",
174 "=price_A", "annual=price_A,annual=price_B",
175 "annual=price_A,monthly=price_A", // one price in two plans
176 "annual=price_A|price_A", // one price twice in a plan
177 "annual=price_A,monthly=price_B|price_A",
178 } {
179 if _, err := abono.ParsePlans(bad); err == nil {
180 t.Errorf("%q parsed, want an error", bad)
181 }
182 }
183}
184