package abono_test import ( "bytes" "context" "fmt" "log/slog" "strings" "testing" "time" "amadan.net/rastrillo/rastrillo/db" "amadan.net/rastrillo/rastrillo/migrate" "amadan.net/rastrillo/rastrillo/sessions" "amadan.net/rastrillo/abono" ) // t0 is the fixed "now" the service tests run at. var t0 = time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC) // newService is a service over a fresh database with abono's schema // applied, its clock fixed at *now. cfg's DB and Now are filled in. func newService(t *testing.T, now *time.Time, cfg abono.Config) (*abono.Service, *testDB) { t.Helper() d := openDB(t) if _, err := migrate.Apply(context.Background(), d, migrate.Merge(sessions.Schema, abono.Schema)); err != nil { t.Fatalf("migrate.Apply: %v", err) } cfg.DB = d.G if cfg.Logger == nil { // Quiet: a test that reads the log passes its own. cfg.Logger = slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)) } cfg.Now = func() time.Time { return *now } s, err := abono.New(cfg) if err != nil { t.Fatalf("abono.New: %v", err) } return s, &testDB{t: t, d: d} } // testDB is a database with a few raw helpers, for setting up rows the // service has no method to write and reading back what it wrote. type testDB struct { t *testing.T d *db.DB } func (x *testDB) exec(query string, args ...any) { x.t.Helper() if _, err := x.d.Writer().Exec(query, args...); err != nil { x.t.Fatalf("%s: %v", query, err) } } // dump runs query and returns each row as its columns joined by "|" // (NULL as ""), one row per line. func (x *testDB) dump(query string, args ...any) string { x.t.Helper() rows, err := x.d.Writer().Query(query, args...) if err != nil { x.t.Fatalf("%s: %v", query, err) } defer rows.Close() cols, _ := rows.Columns() var out strings.Builder for rows.Next() { vals := make([]any, len(cols)) ptrs := make([]any, len(cols)) for i := range vals { ptrs[i] = &vals[i] } if err := rows.Scan(ptrs...); err != nil { x.t.Fatalf("%s: %v", query, err) } for i, v := range vals { if i > 0 { out.WriteString("|") } if v != nil { fmt.Fprint(&out, v) } } out.WriteString("\n") } if err := rows.Err(); err != nil { x.t.Fatalf("%s: %v", query, err) } return out.String() } func TestNewRefusesHalfAConfiguration(t *testing.T) { d := openDB(t) for name, cfg := range map[string]abono.Config{ "a key without secrets": {DB: d.G, Key: "rk_test_1"}, "secrets without a key": {DB: d.G, WebhookSecrets: []string{"whsec_1"}}, "a blank secret": {DB: d.G, Key: "rk_test_1", WebhookSecrets: []string{""}}, "no database": {}, } { if s, err := abono.New(cfg); err == nil { t.Errorf("%s: New succeeded (On=%v), want an error", name, s.On()) } } s, err := abono.New(abono.Config{DB: d.G}) if err != nil { t.Fatalf("New with nothing configured: %v", err) } if s.On() { t.Error("On() with nothing configured, want false") } s, err = abono.New(abono.Config{DB: d.G, Key: "rk_test_1", WebhookSecrets: []string{"whsec_1", "whsec_2"}}) if err != nil { t.Fatalf("New with a key and secrets: %v", err) } if !s.On() { t.Error("On() with a key and secrets, want true") } var nilService *abono.Service if nilService.On() { t.Error("a nil *Service reports On()") } } func TestNewRefusesSandboxOffLoopback(t *testing.T) { d := openDB(t) for _, origin := range []string{"https://example.com", "http://192.168.1.5:8080", "http://localhost.example.com", ""} { if _, err := abono.New(abono.Config{DB: d.G, Sandbox: true, Origin: origin}); err == nil { t.Errorf("Sandbox with Origin %q succeeded, want an error", origin) } } for _, origin := range []string{"http://localhost:8080", "http://app.localhost", "http://127.0.0.1:3000", "http://[::1]:3000"} { s, err := abono.New(abono.Config{DB: d.G, Sandbox: true, Origin: origin}) if err != nil { t.Errorf("Sandbox with Origin %q: %v", origin, err) continue } if !s.On() { t.Errorf("Sandbox with Origin %q: On() is false, want the sandbox's fake Stripe", origin) } } if _, err := abono.New(abono.Config{DB: d.G, Sandbox: true, Origin: "http://localhost:8080", Key: "rk_test_1"}); err == nil { t.Error("Sandbox with a Key succeeded, want an error") } } func TestParsePlans(t *testing.T) { plans, err := abono.ParsePlans("annual=price_A|price_old1|price_old2, monthly=price_B") if err != nil { t.Fatalf("ParsePlans: %v", err) } if len(plans) != 2 || plans[0].Plan != "annual" || plans[0].Sold != "price_A" || strings.Join(plans[0].Legacy, ",") != "price_old1,price_old2" || plans[1].Plan != "monthly" || plans[1].Sold != "price_B" || len(plans[1].Legacy) != 0 { t.Fatalf("plans = %+v", plans) } if plans, err := abono.ParsePlans(""); err != nil || len(plans) != 0 { t.Errorf("empty: %+v %v", plans, err) } if plans, err := abono.ParsePlans("a=p1\nb=p2"); err != nil || len(plans) != 2 { t.Errorf("newline separated: %+v %v", plans, err) } for _, bad := range []string{ "annual", "Annual=price_A", "annual=", "annual=price_A||price_B", "a b=price", "=price_A", "annual=price_A,annual=price_B", "annual=price_A,monthly=price_A", // one price in two plans "annual=price_A|price_A", // one price twice in a plan "annual=price_A,monthly=price_B|price_A", } { if _, err := abono.ParsePlans(bad); err == nil { t.Errorf("%q parsed, want an error", bad) } } }