main pico / pkg / db / postgres / pgtest / pgtest.go
Antonio Mika  ·  2026-10-10
  1// Package pgtest starts a postgres with pico's schema for tests.
  2package pgtest
  3
  4import (
  5	"context"
  6	"errors"
  7	"fmt"
  8	"net/url"
  9	"os"
 10	"os/exec"
 11	"path/filepath"
 12	"runtime"
 13	"sort"
 14	"sync"
 15	"testing"
 16	"time"
 17
 18	"github.com/jmoiron/sqlx"
 19	_ "github.com/lib/pq"
 20	"github.com/picosh/pico/pkg/db"
 21	"github.com/testcontainers/testcontainers-go"
 22	"github.com/testcontainers/testcontainers-go/modules/postgres"
 23	"github.com/testcontainers/testcontainers-go/wait"
 24)
 25
 26var (
 27	once     sync.Once
 28	dbURL    string
 29	stop     = func() {}
 30	startErr error
 31)
 32
 33// URL is the connection string of a postgres with pico's schema, started
 34// the first time a test in the package asks for one. It is the database
 35// TEST_DATABASE_URL names, or a container. A test skips when there is
 36// neither, and fails when REQUIRE_TESTCONTAINERS=1.
 37func URL(t testing.TB) string {
 38	t.Helper()
 39	once.Do(func() {
 40		var started func()
 41		dbURL, started, startErr = start(context.Background())
 42		if startErr == nil {
 43			stop = started
 44		}
 45	})
 46	if startErr != nil {
 47		if os.Getenv("REQUIRE_TESTCONTAINERS") == "1" {
 48			t.Fatalf("postgres: %v", startErr)
 49		}
 50		t.Skipf("postgres unavailable, set TEST_DATABASE_URL or start docker: %v", startErr)
 51	}
 52	return dbURL
 53}
 54
 55// Main runs a package's tests and stops the postgres they started.
 56func Main(m *testing.M) {
 57	code := m.Run()
 58	stop()
 59	os.Exit(code)
 60}
 61
 62// Clean deletes every row the tests wrote.
 63func Clean(t testing.TB, d *sqlx.DB) {
 64	t.Helper()
 65	tables := []string{
 66		"access_logs", "tuns_event_logs", "analytics_visits",
 67		"feed_items", "post_aliases", "post_tags", "posts",
 68		"projects", "feature_flags", "payment_history", "tokens",
 69		"public_keys", "pipe_monitors", "app_users",
 70	}
 71	for _, table := range tables {
 72		if _, err := d.Exec(fmt.Sprintf("DELETE FROM %s", table)); err != nil {
 73			t.Fatalf("failed to clean up %s: %v", table, err)
 74		}
 75	}
 76}
 77
 78// InsertPost inserts post, publishing and updating it now unless it says
 79// otherwise.
 80func InsertPost(t testing.TB, d interface {
 81	InsertPost(post *db.Post) (*db.Post, error)
 82}, post *db.Post) *db.Post {
 83	t.Helper()
 84	now := time.Now()
 85	if post.UpdatedAt == nil {
 86		post.UpdatedAt = &now
 87	}
 88	if post.PublishAt == nil {
 89		post.PublishAt = &now
 90	}
 91	created, err := d.InsertPost(post)
 92	if err != nil {
 93		t.Fatalf("InsertPost failed: %v", err)
 94	}
 95	return created
 96}
 97
 98func start(ctx context.Context) (string, func(), error) {
 99	// Packages test in parallel, so each gets its own database.
100	if base := os.Getenv("TEST_DATABASE_URL"); base != "" {
101		return createDatabase(base)
102	}
103
104	if err := exec.Command("docker", "info").Run(); err != nil {
105		return "", nil, fmt.Errorf("docker: %w", err)
106	}
107	container, err := postgres.Run(ctx,
108		"postgres:14",
109		postgres.WithDatabase("pico_test"),
110		postgres.WithUsername("postgres"),
111		postgres.WithPassword("postgres"),
112		testcontainers.WithWaitStrategy(
113			wait.ForLog("database system is ready to accept connections").
114				WithOccurrence(2).
115				WithStartupTimeout(30*time.Second)),
116	)
117	if err != nil {
118		return "", nil, err
119	}
120	terminate := func() { _ = container.Terminate(ctx) }
121	connStr, err := container.ConnectionString(ctx, "sslmode=disable")
122	if err == nil {
123		err = migrate(connStr)
124	}
125	if err != nil {
126		terminate()
127		return "", nil, err
128	}
129	return connStr, terminate, nil
130}
131
132func createDatabase(base string) (string, func(), error) {
133	server, err := sqlx.Connect("postgres", base)
134	if err != nil {
135		return "", nil, err
136	}
137	name := fmt.Sprintf("pico_test_%d_%d", os.Getpid(), time.Now().UnixNano())
138	if _, err := server.Exec("CREATE DATABASE " + name); err != nil {
139		_ = server.Close()
140		return "", nil, err
141	}
142	drop := func() {
143		_, _ = server.Exec("DROP DATABASE " + name + " WITH (FORCE)")
144		_ = server.Close()
145	}
146	u, err := url.Parse(base)
147	if err == nil {
148		u.Path = "/" + name
149		err = migrate(u.String())
150	}
151	if err != nil {
152		drop()
153		return "", nil, err
154	}
155	return u.String(), drop, nil
156}
157
158func migrate(connStr string) error {
159	d, err := sqlx.Connect("postgres", connStr)
160	if err != nil {
161		return err
162	}
163	defer func() { _ = d.Close() }()
164
165	_, filename, _, ok := runtime.Caller(0)
166	if !ok {
167		return errors.New("failed to get current file path")
168	}
169	migrationsDir := filepath.Join(filepath.Dir(filename), "..", "..", "..", "..", "sql", "migrations")
170	entries, err := os.ReadDir(migrationsDir)
171	if err != nil {
172		return fmt.Errorf("failed to read migrations directory: %w", err)
173	}
174	// They're date-prefixed, so alphabetical order is correct.
175	var migrationFiles []string
176	for _, entry := range entries {
177		if !entry.IsDir() && filepath.Ext(entry.Name()) == ".sql" {
178			migrationFiles = append(migrationFiles, entry.Name())
179		}
180	}
181	sort.Strings(migrationFiles)
182	for _, name := range migrationFiles {
183		content, err := os.ReadFile(filepath.Join(migrationsDir, name))
184		if err != nil {
185			return fmt.Errorf("failed to read migration %s: %w", name, err)
186		}
187		if _, err := d.Exec(string(content)); err != nil {
188			return fmt.Errorf("failed to execute migration %s: %w", name, err)
189		}
190	}
191	return nil
192}