feat: drop sqlite support
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m27s

This commit is contained in:
juancwu 2026-05-04 00:29:45 +00:00
commit da718427bd
27 changed files with 1296 additions and 115 deletions

View file

@ -23,8 +23,7 @@ func TestConfig() *config.Config {
AppURL: "http://localhost:9999",
Host: "127.0.0.1",
Port: "9999",
DBDriver: "sqlite",
DBConnection: ":memory:",
DBConnection: "",
JWTSecret: "test-secret-key-for-testing-only",
JWTExpiry: 24 * time.Hour,
TokenMagicLinkExpiry: 10 * time.Minute,

View file

@ -0,0 +1,118 @@
package testutil
import (
"database/sql"
"fmt"
"net"
"os"
"os/exec"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
// PostgresMain is the TestMain entry point used by every package whose tests touch
// the database. It guarantees a running PostgreSQL 17 instance for the duration of
// the test binary:
//
// - If BUDGIT_TEST_POSTGRES_URL is already set, it is used as-is. CI and `task test`
// hit this path.
// - Otherwise an ephemeral `postgres:17-alpine` container is started on a free
// local port, BUDGIT_TEST_POSTGRES_URL is exported to it for the test process,
// and the container is removed when the test binary exits — even on panic, via
// a deferred cleanup around m.Run().
//
// Usage in each test package:
//
// func TestMain(m *testing.M) { testutil.PostgresMain(m) }
func PostgresMain(m *testing.M) {
if os.Getenv("BUDGIT_TEST_POSTGRES_URL") != "" {
os.Exit(m.Run())
}
if _, err := exec.LookPath("docker"); err != nil {
fmt.Fprintln(os.Stderr, "testutil.PostgresMain: BUDGIT_TEST_POSTGRES_URL is unset and `docker` is not on PATH; cannot run db tests")
os.Exit(1)
}
port, err := freePort()
if err != nil {
fmt.Fprintf(os.Stderr, "testutil.PostgresMain: failed to find free port: %v\n", err)
os.Exit(1)
}
containerName := fmt.Sprintf("budgit-test-pg-%d-%d", os.Getpid(), time.Now().UnixNano())
startCmd := exec.Command("docker", "run", "--rm", "-d",
"--name", containerName,
"-p", fmt.Sprintf("%d:5432", port),
"-e", "POSTGRES_USER=budgit_test",
"-e", "POSTGRES_PASSWORD=testpass",
"-e", "POSTGRES_DB=budgit_test",
// tmpfs for the data dir keeps tests fast — we don't care about durability.
"--tmpfs", "/var/lib/postgresql/data:rw",
"postgres:17-alpine",
)
if out, err := startCmd.CombinedOutput(); err != nil {
fmt.Fprintf(os.Stderr, "testutil.PostgresMain: docker run failed: %v\n%s\n", err, out)
os.Exit(1)
}
stop := func() {
// `docker rm -f` because --rm only fires on a clean exit; force-stop the
// container regardless of state so leftover containers don't accumulate.
_ = exec.Command("docker", "rm", "-f", containerName).Run()
}
url := fmt.Sprintf("postgres://budgit_test:testpass@127.0.0.1:%d/budgit_test?sslmode=disable", port)
if err := waitForPostgres(url, 60*time.Second); err != nil {
stop()
fmt.Fprintf(os.Stderr, "testutil.PostgresMain: postgres did not become ready: %v\n", err)
os.Exit(1)
}
if err := os.Setenv("BUDGIT_TEST_POSTGRES_URL", url); err != nil {
stop()
fmt.Fprintf(os.Stderr, "testutil.PostgresMain: setenv failed: %v\n", err)
os.Exit(1)
}
// Run tests, then ALWAYS stop the container — including on panic.
code := func() int {
defer stop()
return m.Run()
}()
os.Exit(code)
}
func freePort() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port, nil
}
// waitForPostgres polls until a real client connection succeeds. pg_isready isn't
// sufficient under load — under parallel `go test ./...` we've seen it report ready
// while client connections still fail with "unexpected EOF" because the server is
// still finishing startup.
func waitForPostgres(url string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
db, err := sql.Open("pgx", url)
if err == nil {
if err = db.Ping(); err == nil {
_ = db.Close()
return nil
}
_ = db.Close()
}
lastErr = err
time.Sleep(200 * time.Millisecond)
}
return fmt.Errorf("timed out after %s: %w", timeout, lastErr)
}

View file

@ -7,6 +7,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
)
// CreateTestUser inserts a user directly into the database.
@ -79,6 +80,52 @@ func CreateTestSpace(t *testing.T, db *sqlx.DB, ownerID, name string) *model.Spa
return space
}
// CreateTestAccount inserts an account directly into the database.
func CreateTestAccount(t *testing.T, db *sqlx.DB, spaceID, name string) *model.Account {
t.Helper()
now := time.Now()
account := &model.Account{
ID: uuid.NewString(),
Name: name,
SpaceID: spaceID,
Balance: decimal.Zero,
CreatedAt: now,
UpdatedAt: now,
}
_, err := db.Exec(
`INSERT INTO accounts (id, name, space_id, balance, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)`,
account.ID, account.Name, account.SpaceID, account.Balance, account.CreatedAt, account.UpdatedAt,
)
if err != nil {
t.Fatalf("CreateTestAccount: %v", err)
}
return account
}
// CreateTestTransaction inserts a transaction directly into the database.
func CreateTestTransaction(t *testing.T, db *sqlx.DB, accountID, title string, txnType model.TransactionType, amount decimal.Decimal) *model.Transaction {
t.Helper()
now := time.Now()
txn := &model.Transaction{
ID: uuid.NewString(),
Value: amount,
Type: txnType,
AccountID: accountID,
Title: title,
OccurredAt: now,
CreatedAt: now,
UpdatedAt: now,
}
_, err := db.Exec(
`INSERT INTO transactions (id, value, type, account_id, title, description, occurred_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
txn.ID, txn.Value, txn.Type, txn.AccountID, txn.Title, txn.Description, txn.OccurredAt, txn.CreatedAt, txn.UpdatedAt,
)
if err != nil {
t.Fatalf("CreateTestTransaction: %v", err)
}
return txn
}
// CreateTestToken inserts a token directly into the database.
func CreateTestToken(t *testing.T, db *sqlx.DB, userID, tokenType, tokenString string, expiresAt time.Time) *model.Token {
t.Helper()

View file

@ -10,26 +10,24 @@ import (
"github.com/jmoiron/sqlx"
)
// DBInfo holds a test database connection and its driver name.
// DBInfo holds a test database connection.
type DBInfo struct {
DB *sqlx.DB
Driver string
DB *sqlx.DB
}
// ForEachDB runs the given test function against both SQLite and PostgreSQL.
// PostgreSQL tests are skipped when BUDGIT_TEST_POSTGRES_URL is unset.
// ForEachDB runs the test function against PostgreSQL. Skips when
// BUDGIT_TEST_POSTGRES_URL is not set so quick local runs don't fail
// without a database. CI must always set it.
//
// Each test gets its own schema for isolation; the schema is dropped on
// cleanup. The function name is preserved for backwards compatibility,
// although there is now only one engine.
func ForEachDB(t *testing.T, fn func(t *testing.T, dbi DBInfo)) {
t.Helper()
t.Run("sqlite", func(t *testing.T) {
t.Parallel()
dbi := newSQLiteDB(t)
fn(t, dbi)
})
pgURL := os.Getenv("BUDGIT_TEST_POSTGRES_URL")
if pgURL == "" {
t.Log("skipping postgres tests: BUDGIT_TEST_POSTGRES_URL not set")
t.Skip("skipping db tests: BUDGIT_TEST_POSTGRES_URL not set")
return
}
@ -40,55 +38,25 @@ func ForEachDB(t *testing.T, fn func(t *testing.T, dbi DBInfo)) {
})
}
func newSQLiteDB(t *testing.T) DBInfo {
t.Helper()
// Use a unique in-memory database per test via a unique DSN.
// Each file::memory:?cache=shared&name=X uses a separate in-memory DB.
safeName := strings.ReplaceAll(t.Name(), "/", "_")
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared&_pragma=foreign_keys(1)", safeName)
sqliteDB, err := sqlx.Connect("sqlite", dsn)
if err != nil {
t.Fatalf("failed to connect to sqlite: %v", err)
}
// SQLite in-memory DBs are destroyed when the last connection closes.
// Keep at least one open so it survives the test.
sqliteDB.SetMaxOpenConns(1)
t.Cleanup(func() { sqliteDB.Close() })
err = db.RunMigrations(sqliteDB.DB, "sqlite")
if err != nil {
t.Fatalf("failed to run sqlite migrations: %v", err)
}
return DBInfo{DB: sqliteDB, Driver: "sqlite"}
}
func newPostgresDB(t *testing.T, baseURL string) DBInfo {
t.Helper()
// Create a unique schema per test to ensure isolation.
// Create a unique schema per test for isolation.
safeName := strings.ReplaceAll(t.Name(), "/", "_")
safeName = strings.ReplaceAll(safeName, " ", "_")
schema := fmt.Sprintf("test_%s", safeName)
// Connect to the base database to create the schema.
baseDB, err := sqlx.Connect("pgx", baseURL)
if err != nil {
t.Fatalf("failed to connect to postgres: %v", err)
}
_, err = baseDB.Exec(fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %q", schema))
if err != nil {
if _, err := baseDB.Exec(fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %q", schema)); err != nil {
baseDB.Close()
t.Fatalf("failed to create schema %s: %v", schema, err)
}
baseDB.Close()
// Connect with a single-connection pool and set search_path to the new schema.
// MaxOpenConns(1) ensures all queries reuse the same connection where
// MaxOpenConns(1) ensures every query reuses the connection where
// search_path is set (SET is session-level in PostgreSQL).
pgDB, err := sqlx.Connect("pgx", baseURL)
if err != nil {
@ -96,15 +64,13 @@ func newPostgresDB(t *testing.T, baseURL string) DBInfo {
}
pgDB.SetMaxOpenConns(1)
_, err = pgDB.Exec(fmt.Sprintf(`SET search_path TO "%s"`, schema))
if err != nil {
if _, err := pgDB.Exec(fmt.Sprintf(`SET search_path TO "%s"`, schema)); err != nil {
pgDB.Close()
t.Fatalf("failed to set search_path to %s: %v", schema, err)
}
t.Cleanup(func() {
pgDB.Close()
// Drop the schema after the test.
cleanDB, err := sqlx.Connect("pgx", baseURL)
if err == nil {
cleanDB.Exec(fmt.Sprintf("DROP SCHEMA IF EXISTS %q CASCADE", schema))
@ -112,10 +78,9 @@ func newPostgresDB(t *testing.T, baseURL string) DBInfo {
}
})
err = db.RunMigrations(pgDB.DB, "pgx")
if err != nil {
if err := db.RunMigrations(pgDB.DB); err != nil {
t.Fatalf("failed to run postgres migrations: %v", err)
}
return DBInfo{DB: pgDB, Driver: "pgx"}
return DBInfo{DB: pgDB}
}