| 1 | package abono_test |
| 2 | |
| 3 | import ( |
| 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. |
| 20 | var 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. |
| 24 | func 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. |
| 46 | type testDB struct { |
| 47 | t *testing.T |
| 48 | d *db.DB |
| 49 | } |
| 50 | |
| 51 | func (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. |
| 60 | func (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 | |
| 94 | func 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 | |
| 130 | func 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 | |
| 153 | func 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 | |