Files
Crussell/backend/testutils/testdb/testdb.go
T
popertotsandSisyphus b549716b89 test: fix flaky time_blockers test and improve testdb migration
Un-harden TestTimeBlockers_List dates (now relative to current time) and
update TestCleanupOldReservations to use fixture users instead of hardcoded IDs.
Improve Migrate() to drop types before tables (CASCADE dependency fix) and
add forgiven_no_shows to truncate list. Seed default working hours in admin
booking tests to prevent business-logic check failures.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-29 22:55:58 +01:00

242 lines
5.8 KiB
Go

//go:build test
// +build test
package testdb
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const defaultTestDSN = "postgres://myuser:mypassword@localhost:5432/crussell_test?sslmode=disable"
func Pool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TEST_DB_DSN")
if dsn == "" {
dsn = defaultTestDSN
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("Failed to connect to test database: %v", err)
}
if err := pool.Ping(ctx); err != nil {
t.Fatalf("Failed to ping test database: %v", err)
}
return pool
}
func NewPool(dsn string) (*pgxpool.Pool, error) {
if dsn == "" {
dsn = defaultTestDSN
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("failed to create pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
return pool, nil
}
func Migrate(t *testing.T, pool *pgxpool.Pool) {
t.Helper()
ctx := context.Background()
// Check if database already has tables or types
var err error
var typeCount int
err = pool.QueryRow(ctx, "SELECT COUNT(*) FROM pg_type WHERE typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')").Scan(&typeCount)
if err == nil && typeCount > 0 {
t.Log("Database already has types, dropping and recreating for clean state...")
// Drop TYPES FIRST (they have CASCADE dependencies on tables)
typeDrops := []string{
"DROP TYPE IF EXISTS account_role CASCADE",
"DROP TYPE IF EXISTS account_type CASCADE",
"DROP TYPE IF EXISTS payment_type CASCADE",
"DROP TYPE IF EXISTS payment_method CASCADE",
"DROP TYPE IF EXISTS payment_status CASCADE",
"DROP TYPE IF EXISTS booking_status CASCADE",
"DROP TYPE IF EXISTS verification_purpose CASCADE",
"DROP TYPE IF EXISTS admin_notification_reason CASCADE",
}
for _, stmt := range typeDrops {
if _, err := pool.Exec(ctx, stmt); err != nil {
t.Logf("Warning dropping type: %v (expected if using IF EXISTS)", err)
}
}
// Drop all tables, sequences, and views in correct order
dropOrder := []string{
"forgiven_no_shows",
"admin_notifications",
"user_notification_preferences",
"user_referrals",
"booking_services",
"payments",
"bookings",
"user_patch_tests",
"patch_tests",
"booking_edit_requests",
"services",
"verification_codes",
"user_social_logins",
"users",
"images",
"tags",
"time_blockers",
"working_hours",
"exceptional_group_applications",
"exceptional_working_hours",
"exceptional_working_hours_groups",
"business_settings",
}
for _, table := range dropOrder {
stmt := fmt.Sprintf("DROP TABLE IF EXISTS %s CASCADE", table)
if _, err := pool.Exec(ctx, stmt); err != nil {
t.Logf("Warning dropping table %s: %v (expected if using IF EXISTS)", table, err)
}
}
// Drop sequences last
seqDrops := []string{
"DROP SEQUENCE IF EXISTS invoice_number_seq",
"DROP SEQUENCE IF EXISTS tags_id_seq",
"DROP SEQUENCE IF EXISTS exceptional_working_hours_id_seq",
"DROP SEQUENCE IF EXISTS exceptional_working_hours_groups_id_seq",
}
for _, stmt := range seqDrops {
if _, err := pool.Exec(ctx, stmt); err != nil {
t.Logf("Warning dropping sequence: %v", err)
}
}
}
paths := []string{
"../../../init-scripts/init-script.sql",
"../../init-scripts/init-script.sql",
"../init-scripts/init-script.sql",
"init-scripts/init-script.sql",
}
var schemaSQL string
for _, p := range paths {
if data, err := os.ReadFile(p); err == nil {
schemaSQL = string(data)
break
}
}
if schemaSQL == "" {
t.Fatal("Could not find init-script.sql in any expected location")
}
// Simple migration: just create tables that don't exist
// Note: This doesn't handle stored procedures properly, but the database
// should already be set up with the correct schema
t.Log("Running migration...")
// Execute the schema SQL
_, err = pool.Exec(ctx, schemaSQL)
if err != nil {
t.Fatalf("Failed to execute migration: %v", err)
}
}
func Tx(t *testing.T, pool *pgxpool.Pool) pgx.Tx {
t.Helper()
ctx := context.Background()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("Failed to begin transaction: %v", err)
}
return tx
}
func TxWithRollback(t *testing.T, pool *pgxpool.Pool) (pgx.Tx, func()) {
tx := Tx(t, pool)
return tx, func() {
tx.Rollback(context.Background())
}
}
func TruncateTables(t *testing.T, pool *pgxpool.Pool) {
t.Helper()
ctx := context.Background()
tables := []string{
"forgiven_no_shows",
"user_social_logins",
"verification_codes",
"booking_services",
"payments",
"bookings",
"user_patch_tests",
"patch_tests",
"services",
"admin_notifications",
"user_referrals",
"user_notification_preferences",
"time_blockers",
"working_hours",
"exceptional_working_hours",
"exceptional_working_hours_groups",
"users",
"images",
"tags",
}
for _, table := range tables {
_, err := pool.Exec(ctx, fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table))
if err != nil {
t.Logf("Warning: could not truncate %s: %v", table, err)
}
}
}
func FindInitScript() (string, error) {
cwd, err := os.Getwd()
if err != nil {
cwd = ""
}
paths := []string{
"../../../init-scripts/init-script.sql",
"../../init-scripts/init-script.sql",
"../init-scripts/init-script.sql",
"init-scripts/init-script.sql",
}
if cwd != "" {
paths = append(paths, filepath.Join(cwd, "..", "..", "init-scripts", "init-script.sql"))
}
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
return "", fmt.Errorf("could not find init-script.sql")
}