// Package testsupport wires tests to a real PostgreSQL database. // // Isolation strategy: every test gets its own freshly created tenant. Because // the whole application is tenant-scoped by design, a per-test tenant gives // each test a private world without truncating tables between runs -- and it // exercises the tenant scoping on every single query rather than trusting it. // // Set TEST_DATABASE_URL to point at a throwaway database. The default assumes // a local "travelhub_test". The schema is applied automatically on first use. package testsupport import ( "context" "fmt" "net/url" "os" "path/filepath" "runtime" "sync" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/bcrypt" "github.com/travelswitch/travelhub/backend/internal/config" "github.com/travelswitch/travelhub/backend/internal/models" "github.com/travelswitch/travelhub/backend/internal/repository" ) // Password is the plaintext behind every user created by this package. const Password = "testpass123" var ( poolOnce sync.Once pool *pgxpool.Pool poolErr error ) // Fixture is one test's private slice of the world. type Fixture struct { Pool *pgxpool.Pool Repo *repository.Repository Tenant models.Tenant Admin models.User Agent models.User } // Setup returns a Fixture backed by a brand-new tenant. The tenant and // everything that cascades from it are removed when the test finishes. func Setup(t *testing.T) *Fixture { t.Helper() p := sharedPool(t) ctx := context.Background() slug := "test-" + uuid.NewString()[:8] var tenant models.Tenant err := p.QueryRow(ctx, `INSERT INTO tenants (slug, name) VALUES ($1, $2) RETURNING id, slug, name, status, created_at`, slug, "Test Agency "+slug, ).Scan(&tenant.ID, &tenant.Slug, &tenant.Name, &tenant.Status, &tenant.CreatedAt) if err != nil { t.Fatalf("create tenant: %v", err) } t.Cleanup(func() { // ON DELETE CASCADE clears users, flights, bookings and passengers. if _, err := p.Exec(context.Background(), `DELETE FROM tenants WHERE id = $1`, tenant.ID); err != nil { t.Logf("cleanup tenant %s: %v", tenant.Slug, err) } }) f := &Fixture{Pool: p, Repo: repository.New(p), Tenant: tenant} f.Admin = f.NewUser(t, "admin@"+slug+".test", "admin") f.Agent = f.NewUser(t, "agent@"+slug+".test", "agent") return f } // NewUser adds a user to the fixture's tenant. func (f *Fixture) NewUser(t *testing.T, email, role string) models.User { t.Helper() // MinCost keeps the suite fast; the middleware verifies any cost. hash, err := bcrypt.GenerateFromPassword([]byte(Password), bcrypt.MinCost) if err != nil { t.Fatalf("hash password: %v", err) } var u models.User err = f.Pool.QueryRow(context.Background(), `INSERT INTO users (tenant_id, email, password_hash, full_name, role) VALUES ($1, $2, $3, $4, $5) RETURNING id, tenant_id, email, password_hash, full_name, role, created_at`, f.Tenant.ID, email, string(hash), "Test "+role, role, ).Scan(&u.ID, &u.TenantID, &u.Email, &u.PasswordHash, &u.FullName, &u.Role, &u.CreatedAt) if err != nil { t.Fatalf("create user %s: %v", email, err) } return u } // FlightOpts describes a flight to insert. Zero values get sensible defaults. type FlightOpts struct { Number string Origin string // IATA, default JFK Destination string // IATA, default LHR DepartsIn time.Duration Duration time.Duration Cabin string Price float64 Seats int Available *int // defaults to Seats Status string } // NewFlight inserts one flight into the fixture's tenant and returns it. func (f *Fixture) NewFlight(t *testing.T, opts FlightOpts) models.Flight { t.Helper() ctx := context.Background() if opts.Number == "" { opts.Number = "TT" + uuid.NewString()[:4] } if opts.Origin == "" { opts.Origin = "JFK" } if opts.Destination == "" { opts.Destination = "LHR" } if opts.DepartsIn == 0 { opts.DepartsIn = 48 * time.Hour } if opts.Duration == 0 { opts.Duration = 7 * time.Hour } if opts.Cabin == "" { opts.Cabin = "economy" } if opts.Price == 0 { opts.Price = 500 } if opts.Seats == 0 { opts.Seats = 100 } if opts.Status == "" { opts.Status = "scheduled" } available := opts.Seats if opts.Available != nil { available = *opts.Available } departs := time.Now().Add(opts.DepartsIn) var id uuid.UUID err := f.Pool.QueryRow(ctx, `INSERT INTO flights (tenant_id, airline_id, flight_number, origin_airport_id, destination_airport_id, departure_time, arrival_time, aircraft, cabin_class, base_price, currency, seats_total, seats_available, status) VALUES ($1, (SELECT id FROM airlines WHERE iata_code = 'AA'), $2, (SELECT id FROM airports WHERE iata_code = $3), (SELECT id FROM airports WHERE iata_code = $4), $5, $6, 'Boeing 787-9', $7, $8, 'USD', $9, $10, $11) RETURNING id`, f.Tenant.ID, opts.Number, opts.Origin, opts.Destination, departs, departs.Add(opts.Duration), opts.Cabin, opts.Price, opts.Seats, available, opts.Status, ).Scan(&id) if err != nil { t.Fatalf("create flight: %v", err) } flight, err := f.Repo.FlightByID(ctx, f.Tenant.ID, id) if err != nil { t.Fatalf("read back flight: %v", err) } return flight } // SeatsAvailable reads the current availability straight from the row, for // assertions about seat accounting. func (f *Fixture) SeatsAvailable(t *testing.T, flightID uuid.UUID) int { t.Helper() var seats int err := f.Pool.QueryRow(context.Background(), `SELECT seats_available FROM flights WHERE id = $1`, flightID).Scan(&seats) if err != nil { t.Fatalf("read seats: %v", err) } return seats } // Suspend flips the tenant to suspended, for tests that check sign-in is // refused afterwards. func (f *Fixture) Suspend(t *testing.T) { t.Helper() if _, err := f.Pool.Exec(context.Background(), `UPDATE tenants SET status = 'suspended' WHERE id = $1`, f.Tenant.ID); err != nil { t.Fatalf("suspend tenant: %v", err) } } // sharedPool opens the pool once per test binary and prepares the schema. func sharedPool(t *testing.T) *pgxpool.Pool { t.Helper() poolOnce.Do(func() { // Pick up backend/.env so a developer who configured the app can run // the tests without setting anything extra. config.LoadDotEnv() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() p, err := pgxpool.New(ctx, databaseURL()) if err != nil { poolErr = fmt.Errorf("open test pool: %w", err) return } if err := p.Ping(ctx); err != nil { poolErr = fmt.Errorf("ping test database: %w", err) return } if err := applySchema(ctx, p); err != nil { poolErr = err return } if err := seedReference(ctx, p); err != nil { poolErr = err return } pool = p }) if poolErr != nil { t.Fatalf("test database unavailable: %v\n"+ "Set TEST_DATABASE_URL, or create the default database with:\n"+ " createdb -U postgres travelhub_test", poolErr) } return pool } // databaseURL resolves the test DSN, preferring TEST_DATABASE_URL and // otherwise assembling one from the same DB_* variables the app uses, with the // database name swapped for the test one. func databaseURL() string { if raw := os.Getenv("TEST_DATABASE_URL"); raw != "" { return raw } user := envOr("DB_USER", "postgres") pass := envOr("DB_PASSWORD", "postgres") host := envOr("DB_HOST", "localhost") port := envOr("DB_PORT", "5432") name := envOr("TEST_DB_NAME", "travelhub_test") u := &url.URL{ Scheme: "postgres", User: url.UserPassword(user, pass), Host: host + ":" + port, Path: "/" + name, RawQuery: "sslmode=disable", } return u.String() } func envOr(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } // applySchema runs 001_init.sql, which is written to be re-runnable. func applySchema(ctx context.Context, p *pgxpool.Pool) error { path := filepath.Join(moduleRoot(), "migrations", "001_init.sql") sql, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read schema %s: %w", path, err) } // Exec with no arguments uses the simple protocol, which accepts a script // of several statements. if _, err := p.Exec(ctx, string(sql)); err != nil { return fmt.Errorf("apply schema: %w", err) } return nil } // seedReference inserts the global airports and airlines the fixtures need. func seedReference(ctx context.Context, p *pgxpool.Pool) error { const sql = ` INSERT INTO airports (iata_code, name, city, country, timezone) VALUES ('JFK', 'John F. Kennedy International', 'New York', 'United States', 'America/New_York'), ('LHR', 'Heathrow', 'London', 'United Kingdom', 'Europe/London'), ('LAX', 'Los Angeles International', 'Los Angeles', 'United States', 'America/Los_Angeles'), ('DXB', 'Dubai International', 'Dubai', 'United Arab Emirates', 'Asia/Dubai') ON CONFLICT (iata_code) DO NOTHING; INSERT INTO airlines (iata_code, name) VALUES ('AA', 'American Airlines'), ('BA', 'British Airways') ON CONFLICT (iata_code) DO NOTHING;` if _, err := p.Exec(ctx, sql); err != nil { return fmt.Errorf("seed reference data: %w", err) } return nil } // moduleRoot locates backend/ from this file's compile-time path, so tests can // find migrations no matter which package directory they run from. func moduleRoot() string { _, thisFile, _, ok := runtime.Caller(0) if !ok { return "." } // .../backend/internal/testsupport/testsupport.go -> .../backend return filepath.Dir(filepath.Dir(filepath.Dir(thisFile))) }