feat: drop sqlite support
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m27s
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m27s
This commit is contained in:
parent
5e00060421
commit
da718427bd
27 changed files with 1296 additions and 115 deletions
|
|
@ -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,
|
||||
|
|
|
|||
118
internal/testutil/postgres_main.go
Normal file
118
internal/testutil/postgres_main.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue