Angular + Go + PostgreSQL sample with strict per-tenant isolation. - Backend: Go API (basic auth, tenant-scoped repository layer, pgx pool) - Frontend: Angular 22 SPA (search, checkout, bookings, dashboard) - Database: schema, seed data and demo bookings migrations - Tests: 311 across Go (integration), Angular (unit) and Playwright (e2e), plus a generated evidence report with PDF export - Security: whitebox VAPT report under security/ Secrets (.env, tokens, DB credentials) are git-ignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
330 lines
9.5 KiB
Go
330 lines
9.5 KiB
Go
// 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)))
|
|
}
|