Initial commit: TravelHub multi-tenant flight booking platform
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>
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
// 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)))
|
||||
}
|
||||
Reference in New Issue
Block a user