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:
nilesh.p
2026-09-13 11:38:01 +05:30
co-authored by Claude Opus 4.8
commit 4d3eb18a6c
129 changed files with 25424 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
// Package auth carries the authenticated caller through the request context.
//
// Every tenant-scoped query in this service takes its tenant id from the
// Identity stored here, never from anything the client can name directly in a
// query string or body. That is the single rule that keeps tenants isolated.
package auth
import (
"context"
"errors"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// Identity is the authenticated user together with the tenant they signed into.
type Identity struct {
User models.User
Tenant models.Tenant
}
// contextKey is unexported so no other package can collide with our key.
type contextKey struct{}
var identityKey contextKey
// ErrNoIdentity means the handler ran without authentication middleware in
// front of it, which is a programming error rather than a client error.
var ErrNoIdentity = errors.New("no identity in request context")
// WithIdentity returns a context carrying the identity.
func WithIdentity(ctx context.Context, id Identity) context.Context {
return context.WithValue(ctx, identityKey, id)
}
// FromContext retrieves the identity placed by the auth middleware.
func FromContext(ctx context.Context) (Identity, error) {
id, ok := ctx.Value(identityKey).(Identity)
if !ok {
return Identity{}, ErrNoIdentity
}
return id, nil
}
// IsAdmin reports whether the caller holds the tenant admin role.
func (i Identity) IsAdmin() bool {
return i.User.Role == "admin"
}
+136
View File
@@ -0,0 +1,136 @@
// Package config loads runtime settings from the environment, optionally
// seeded from a .env file sitting next to the binary or at the module root.
package config
import (
"bufio"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"strings"
)
// Config holds everything the API needs to start.
type Config struct {
DatabaseURL string
Port string
CORSOrigins []string
}
// Load reads .env (if present) into the process environment, then builds a
// Config. Real environment variables always win over .env entries, so
// container and CI overrides behave the way people expect.
func Load() (*Config, error) {
LoadDotEnv()
cfg := &Config{
Port: envOr("PORT", "8080"),
CORSOrigins: splitAndTrim(envOr("CORS_ORIGINS", "http://localhost:4200")),
}
if raw := os.Getenv("DATABASE_URL"); raw != "" {
cfg.DatabaseURL = raw
} else {
user := envOr("DB_USER", "postgres")
pass := os.Getenv("DB_PASSWORD")
host := envOr("DB_HOST", "localhost")
port := envOr("DB_PORT", "5432")
name := envOr("DB_NAME", "travelhub")
ssl := envOr("DB_SSLMODE", "disable")
if pass == "" {
return nil, fmt.Errorf("database password is not set: define DATABASE_URL or DB_PASSWORD (see .env.example)")
}
// url.UserPassword escapes credentials for us, which matters because
// passwords routinely contain @ : / and other reserved characters.
cfg.DatabaseURL = (&url.URL{
Scheme: "postgres",
User: url.UserPassword(user, pass),
Host: net.JoinHostPort(host, port),
Path: "/" + name,
RawQuery: "sslmode=" + ssl,
}).String()
}
return cfg, nil
}
// LoadDotEnv walks up from the working directory looking for a .env file and
// applies any keys that are not already set in the environment. Load calls it;
// it is exported so tests can pick up the same local credentials without
// building a whole Config.
func LoadDotEnv() {
dir, err := os.Getwd()
if err != nil {
return
}
for i := 0; i < 4; i++ {
path := filepath.Join(dir, ".env")
if applyEnvFile(path) {
return
}
parent := filepath.Dir(dir)
if parent == dir {
return
}
dir = parent
}
}
// applyEnvFile reads one .env file. It reports whether the file existed.
func applyEnvFile(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, found := strings.Cut(line, "=")
if !found {
continue
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
// Allow quoted values so passwords may contain leading/trailing spaces.
if len(value) >= 2 {
if (value[0] == '"' && value[len(value)-1] == '"') ||
(value[0] == '\'' && value[len(value)-1] == '\'') {
value = value[1 : len(value)-1]
}
}
if _, exists := os.LookupEnv(key); !exists {
os.Setenv(key, value)
}
}
return true
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func splitAndTrim(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+217
View File
@@ -0,0 +1,217 @@
package config_test
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/travelswitch/travelhub/backend/internal/config"
)
// isolate points the process at an empty directory and clears every variable
// Load reads, so one test cannot inherit another's environment -- or the
// developer's real .env.
func isolate(t *testing.T) {
t.Helper()
for _, key := range []string{
"DATABASE_URL", "DB_HOST", "DB_PORT", "DB_USER", "DB_PASSWORD",
"DB_NAME", "DB_SSLMODE", "PORT", "CORS_ORIGINS",
} {
t.Setenv(key, "")
os.Unsetenv(key)
}
t.Chdir(t.TempDir())
}
func TestLoadDefaults(t *testing.T) {
isolate(t)
t.Setenv("DB_PASSWORD", "secret")
cfg, err := config.Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Port != "8080" {
t.Errorf("Port = %q, want 8080", cfg.Port)
}
if len(cfg.CORSOrigins) != 1 || cfg.CORSOrigins[0] != "http://localhost:4200" {
t.Errorf("CORSOrigins = %v, want the Angular dev server", cfg.CORSOrigins)
}
if !strings.Contains(cfg.DatabaseURL, "/travelhub") {
t.Errorf("DatabaseURL = %q, want the default database name", cfg.DatabaseURL)
}
}
func TestLoadRequiresAPassword(t *testing.T) {
isolate(t)
_, err := config.Load()
if err == nil {
t.Fatal("want an error when neither DATABASE_URL nor DB_PASSWORD is set")
}
if !strings.Contains(err.Error(), "DB_PASSWORD") {
t.Errorf("error should name the missing variable, got: %v", err)
}
}
// The credentials in use here contain '@', which is exactly the character that
// breaks naive string concatenation into a DSN.
func TestLoadEscapesAwkwardPasswords(t *testing.T) {
isolate(t)
awkward := []string{
// Synthetic values only — never a real, in-use password (see the VAPT
// report, FINDING-8). Each still exercises a reserved DSN character.
"S3cr3t@x",
"p@ss:word/with?reserved#chars",
"has spaces and %25 percent",
"trailing@@@",
}
for _, password := range awkward {
t.Run(password, func(t *testing.T) {
t.Setenv("DB_PASSWORD", password)
t.Setenv("DB_USER", "postgres")
t.Setenv("DB_NAME", "travelhub")
cfg, err := config.Load()
if err != nil {
t.Fatalf("load: %v", err)
}
// The real proof is that the driver can parse it back out.
parsed, err := pgxpool.ParseConfig(cfg.DatabaseURL)
if err != nil {
t.Fatalf("pgx could not parse %q: %v", cfg.DatabaseURL, err)
}
if parsed.ConnConfig.Password != password {
t.Errorf("round-tripped password = %q, want %q",
parsed.ConnConfig.Password, password)
}
if parsed.ConnConfig.Database != "travelhub" {
t.Errorf("database = %q, want travelhub", parsed.ConnConfig.Database)
}
})
}
}
func TestDatabaseURLWinsOverParts(t *testing.T) {
isolate(t)
const explicit = "postgres://someone:else@db.internal:6543/other?sslmode=require"
t.Setenv("DATABASE_URL", explicit)
t.Setenv("DB_PASSWORD", "ignored")
t.Setenv("DB_NAME", "ignored")
cfg, err := config.Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.DatabaseURL != explicit {
t.Errorf("DatabaseURL = %q, want the explicit value", cfg.DatabaseURL)
}
}
func TestLoadReadsDotEnv(t *testing.T) {
isolate(t)
dir := t.TempDir()
contents := strings.Join([]string{
"# a comment",
"",
"DB_PASSWORD=from-file",
"DB_NAME=from-file-db",
`DB_USER="quoted-user"`,
"PORT=9999",
"CORS_ORIGINS=http://a.test, http://b.test ,",
"MALFORMED-LINE-WITHOUT-EQUALS",
}, "\n")
if err := os.WriteFile(filepath.Join(dir, ".env"), []byte(contents), 0o600); err != nil {
t.Fatalf("write .env: %v", err)
}
t.Chdir(dir)
cfg, err := config.Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Port != "9999" {
t.Errorf("Port = %q, want 9999 from .env", cfg.Port)
}
if got := cfg.CORSOrigins; len(got) != 2 || got[0] != "http://a.test" || got[1] != "http://b.test" {
t.Errorf("CORSOrigins = %v, want two trimmed origins", got)
}
parsed, err := pgxpool.ParseConfig(cfg.DatabaseURL)
if err != nil {
t.Fatalf("parse: %v", err)
}
if parsed.ConnConfig.Password != "from-file" {
t.Errorf("password = %q, want from-file", parsed.ConnConfig.Password)
}
if parsed.ConnConfig.Database != "from-file-db" {
t.Errorf("database = %q, want from-file-db", parsed.ConnConfig.Database)
}
// Surrounding quotes are stripped rather than taken literally.
if parsed.ConnConfig.User != "quoted-user" {
t.Errorf("user = %q, want quoted-user", parsed.ConnConfig.User)
}
}
// Real environment variables must beat .env, so container and CI overrides work.
func TestEnvironmentBeatsDotEnv(t *testing.T) {
isolate(t)
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, ".env"),
[]byte("DB_PASSWORD=from-file\nPORT=1111\n"), 0o600); err != nil {
t.Fatalf("write .env: %v", err)
}
t.Chdir(dir)
t.Setenv("PORT", "2222")
t.Setenv("DB_PASSWORD", "from-environment")
cfg, err := config.Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Port != "2222" {
t.Errorf("Port = %q, want the environment value 2222", cfg.Port)
}
parsed, _ := pgxpool.ParseConfig(cfg.DatabaseURL)
if parsed.ConnConfig.Password != "from-environment" {
t.Errorf("password = %q, want from-environment", parsed.ConnConfig.Password)
}
}
func TestLoadFindsDotEnvInAParentDirectory(t *testing.T) {
isolate(t)
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".env"),
[]byte("DB_PASSWORD=parent-secret\n"), 0o600); err != nil {
t.Fatalf("write .env: %v", err)
}
nested := filepath.Join(root, "cmd", "api")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
t.Chdir(nested)
cfg, err := config.Load()
if err != nil {
t.Fatalf("load: %v", err)
}
parsed, _ := pgxpool.ParseConfig(cfg.DatabaseURL)
if parsed.ConnConfig.Password != "parent-secret" {
t.Errorf("password = %q, want parent-secret", parsed.ConnConfig.Password)
}
}
+40
View File
@@ -0,0 +1,40 @@
// Package database owns the PostgreSQL connection pool.
package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Connect opens a pooled connection and verifies it with a ping, so a bad
// password or an unreachable server fails at startup rather than on the first
// request.
func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parse database url: %w", err)
}
cfg.MaxConns = 10
cfg.MinConns = 1
cfg.MaxConnLifetime = time.Hour
cfg.MaxConnIdleTime = 30 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("create pool: %w", err)
}
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := pool.Ping(pingCtx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping database: %w", err)
}
return pool, nil
}
+162
View File
@@ -0,0 +1,162 @@
package handlers
import (
"encoding/json"
"net/http"
"net/mail"
"strings"
"github.com/google/uuid"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// ListBookings handles GET /api/bookings, optionally filtered by status.
func (h *Handler) ListBookings(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
status := strings.TrimSpace(r.URL.Query().Get("status"))
if status != "" && status != "confirmed" && status != "cancelled" {
writeError(w, http.StatusBadRequest, "status must be confirmed or cancelled")
return
}
limit := intParam(r, "limit", 25, 1, 100)
offset := intParam(r, "offset", 0, 0, 100000)
bookings, total, err := h.Repo.ListBookings(r.Context(), id.Tenant.ID, status, limit, offset)
if err != nil {
h.fail(w, err, "list bookings")
return
}
writeJSON(w, http.StatusOK, page[models.Booking]{
Items: bookings, Total: total, Limit: limit, Offset: offset,
})
}
// GetBooking handles GET /api/bookings/{id}.
func (h *Handler) GetBooking(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
bookingID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid booking id")
return
}
booking, err := h.Repo.BookingByID(r.Context(), id.Tenant.ID, bookingID)
if err != nil {
h.fail(w, err, "get booking")
return
}
writeJSON(w, http.StatusOK, booking)
}
// CreateBooking handles POST /api/bookings.
func (h *Handler) CreateBooking(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
var req models.CreateBookingRequest
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return
}
if msg := validateBooking(&req); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
booking, err := h.Repo.CreateBooking(r.Context(), id.Tenant.ID, id.User.ID, req)
if err != nil {
h.fail(w, err, "create booking")
return
}
writeJSON(w, http.StatusCreated, booking)
}
// CancelBooking handles POST /api/bookings/{id}/cancel and returns the seats
// to inventory.
func (h *Handler) CancelBooking(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
bookingID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid booking id")
return
}
booking, err := h.Repo.CancelBooking(r.Context(), id.Tenant.ID, bookingID)
if err != nil {
h.fail(w, err, "cancel booking")
return
}
writeJSON(w, http.StatusOK, booking)
}
// validateBooking returns an empty string when the payload is usable, or a
// message naming the first problem. Normalisation happens here too, so the
// repository only ever sees trimmed values.
func validateBooking(req *models.CreateBookingRequest) string {
if req.FlightID == uuid.Nil {
return "flightId is required"
}
req.ContactEmail = strings.TrimSpace(req.ContactEmail)
if req.ContactEmail == "" {
return "contactEmail is required"
}
if _, err := mail.ParseAddress(req.ContactEmail); err != nil {
return "contactEmail is not a valid email address"
}
req.ContactPhone = strings.TrimSpace(req.ContactPhone)
if len(req.Passengers) == 0 {
return "at least one passenger is required"
}
if len(req.Passengers) > 9 {
return "a single booking may carry at most 9 passengers"
}
for i := range req.Passengers {
p := &req.Passengers[i]
p.FirstName = strings.TrimSpace(p.FirstName)
p.LastName = strings.TrimSpace(p.LastName)
p.PassportNumber = strings.TrimSpace(p.PassportNumber)
p.DateOfBirth = strings.TrimSpace(p.DateOfBirth)
if p.FirstName == "" || p.LastName == "" {
return "every passenger needs a first and last name"
}
}
return ""
}
// Dashboard handles GET /api/dashboard.
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
stats, err := h.Repo.DashboardStats(r.Context(), id.Tenant.ID)
if err != nil {
h.fail(w, err, "dashboard stats")
return
}
writeJSON(w, http.StatusOK, stats)
}
+118
View File
@@ -0,0 +1,118 @@
package handlers
import (
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// SearchFlights handles GET /api/flights.
//
// Query parameters: origin, destination (IATA codes), date (YYYY-MM-DD),
// cabin, passengers, sort (price|duration|departure), limit, offset.
func (h *Handler) SearchFlights(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
q := r.URL.Query()
search := models.FlightSearch{
Origin: strings.ToUpper(strings.TrimSpace(q.Get("origin"))),
Destination: strings.ToUpper(strings.TrimSpace(q.Get("destination"))),
CabinClass: strings.TrimSpace(q.Get("cabin")),
Passengers: intParam(r, "passengers", 1, 1, 9),
SortBy: q.Get("sort"),
Limit: intParam(r, "limit", 25, 1, 100),
Offset: intParam(r, "offset", 0, 0, 100000),
}
if raw := strings.TrimSpace(q.Get("date")); raw != "" {
// Parsed in the server's local zone, which is what an agent selling
// from a desk in that zone means by "flights on the 12th".
d, err := time.ParseInLocation("2006-01-02", raw, time.Local)
if err != nil {
writeError(w, http.StatusBadRequest, "date must be in YYYY-MM-DD format")
return
}
search.DepartDate = &d
}
if search.CabinClass != "" && !validCabin(search.CabinClass) {
writeError(w, http.StatusBadRequest,
"cabin must be one of economy, premium_economy, business, first")
return
}
flights, total, err := h.Repo.SearchFlights(r.Context(), id.Tenant.ID, search)
if err != nil {
h.fail(w, err, "search flights")
return
}
writeJSON(w, http.StatusOK, page[models.Flight]{
Items: flights, Total: total, Limit: search.Limit, Offset: search.Offset,
})
}
// GetFlight handles GET /api/flights/{id}.
func (h *Handler) GetFlight(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
flightID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid flight id")
return
}
flight, err := h.Repo.FlightByID(r.Context(), id.Tenant.ID, flightID)
if err != nil {
h.fail(w, err, "get flight")
return
}
writeJSON(w, http.StatusOK, flight)
}
// ListAirports handles GET /api/airports.
func (h *Handler) ListAirports(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
airports, err := h.Repo.ListAirports(r.Context(), id.Tenant.ID)
if err != nil {
h.fail(w, err, "list airports")
return
}
writeJSON(w, http.StatusOK, airports)
}
// ListAirlines handles GET /api/airlines.
func (h *Handler) ListAirlines(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
airlines, err := h.Repo.ListAirlines(r.Context(), id.Tenant.ID)
if err != nil {
h.fail(w, err, "list airlines")
return
}
writeJSON(w, http.StatusOK, airlines)
}
func validCabin(c string) bool {
switch c {
case "economy", "premium_economy", "business", "first":
return true
}
return false
}
+96
View File
@@ -0,0 +1,96 @@
// Package handlers implements the TravelHub JSON API.
//
// Handlers never read a tenant id from the URL, query string, or body. They
// take it from the authenticated identity, which the Basic auth middleware
// resolved from the credentials. That keeps "which tenant am I acting as"
// impossible for a client to influence.
package handlers
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/travelswitch/travelhub/backend/internal/auth"
"github.com/travelswitch/travelhub/backend/internal/repository"
)
// Handler carries the dependencies shared by every endpoint.
type Handler struct {
Repo *repository.Repository
Logger *slog.Logger
}
// New builds a Handler.
func New(repo *repository.Repository, logger *slog.Logger) *Handler {
return &Handler{Repo: repo, Logger: logger}
}
// page is the envelope for every list endpoint.
type page[T any] struct {
Items []T `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if body != nil {
json.NewEncoder(w).Encode(body)
}
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
// fail maps a repository error onto a status code, logging only the ones that
// represent a server fault.
func (h *Handler) fail(w http.ResponseWriter, err error, context string) {
switch {
case errors.Is(err, repository.ErrNotFound):
writeError(w, http.StatusNotFound, "not found")
case errors.Is(err, repository.ErrNoSeats):
writeError(w, http.StatusConflict, "not enough seats available on this flight")
default:
h.Logger.Error(context, "error", err)
writeError(w, http.StatusInternalServerError, "internal server error")
}
}
// identity pulls the authenticated caller out of the request, replying 401 if
// the route was somehow mounted without the auth middleware.
func (h *Handler) identity(w http.ResponseWriter, r *http.Request) (auth.Identity, bool) {
id, err := auth.FromContext(r.Context())
if err != nil {
writeError(w, http.StatusUnauthorized, "authentication required")
return auth.Identity{}, false
}
return id, true
}
// intParam reads a bounded integer query parameter.
func intParam(r *http.Request, name string, fallback, min, max int) int {
raw := r.URL.Query().Get(name)
if raw == "" {
return fallback
}
v, err := strconv.Atoi(raw)
if err != nil || v < min {
return fallback
}
if v > max {
return max
}
return v
}
// Health reports liveness. It is the only endpoint that needs neither
// credentials nor a tenant.
func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+511
View File
@@ -0,0 +1,511 @@
package handlers_test
import (
"bytes"
"encoding/base64"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/travelswitch/travelhub/backend/internal/handlers"
"github.com/travelswitch/travelhub/backend/internal/httpapi"
"github.com/travelswitch/travelhub/backend/internal/middleware"
"github.com/travelswitch/travelhub/backend/internal/models"
"github.com/travelswitch/travelhub/backend/internal/testsupport"
)
// api spins the real router over a per-test tenant, so these exercise the same
// routing, middleware order and auth rules the server runs.
type api struct {
*testsupport.Fixture
handler http.Handler
}
func newAPI(t *testing.T) *api {
t.Helper()
f := testsupport.Setup(t)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
h := handlers.New(f.Repo, logger)
return &api{
Fixture: f,
handler: httpapi.New(h, f.Repo, logger, []string{"http://localhost:4200"}),
}
}
// do issues an authenticated request as the fixture's agent.
func (a *api) do(t *testing.T, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
return a.as(t, a.Agent.Email, testsupport.Password, a.Tenant.Slug, method, path, body)
}
// anon issues a request with no credentials.
func (a *api) anon(t *testing.T, method, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, nil)
rec := httptest.NewRecorder()
a.handler.ServeHTTP(rec, req)
return rec
}
func (a *api) as(t *testing.T, email, password, tenant, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
reader = bytes.NewReader(raw)
}
req := httptest.NewRequest(method, path, reader)
req.Header.Set("Authorization", "Basic "+
base64.StdEncoding.EncodeToString([]byte(email+":"+password)))
req.Header.Set(middleware.TenantHeader, tenant)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
a.handler.ServeHTTP(rec, req)
return rec
}
func decode[T any](t *testing.T, rec *httptest.ResponseRecorder) T {
t.Helper()
var out T
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode response %s: %v", rec.Body, err)
}
return out
}
func requireStatus(t *testing.T, rec *httptest.ResponseRecorder, want int) {
t.Helper()
if rec.Code != want {
t.Fatalf("status = %d, want %d (body: %s)", rec.Code, want, rec.Body)
}
}
// --- public endpoints ------------------------------------------------------
func TestHealthNeedsNoCredentials(t *testing.T) {
a := newAPI(t)
rec := a.anon(t, http.MethodGet, "/api/health")
requireStatus(t, rec, http.StatusOK)
got := decode[map[string]string](t, rec)
if got["status"] != "ok" {
t.Errorf("body = %v, want status ok", got)
}
}
func TestTenantListIsPublicButThin(t *testing.T) {
a := newAPI(t)
rec := a.anon(t, http.MethodGet, "/api/tenants")
requireStatus(t, rec, http.StatusOK)
// The picker needs a slug and a name; it must not leak ids or timestamps.
got := decode[[]map[string]any](t, rec)
found := false
for _, tn := range got {
if tn["slug"] == a.Tenant.Slug {
found = true
if _, leaked := tn["id"]; leaked {
t.Error("tenant list leaked internal ids")
}
if len(tn) != 2 {
t.Errorf("tenant entry has %d fields, want just slug and name: %v", len(tn), tn)
}
}
}
if !found {
t.Error("the fixture's tenant is missing from the public list")
}
}
func TestProtectedRoutesRejectAnonymous(t *testing.T) {
a := newAPI(t)
for _, path := range []string{
"/api/session", "/api/flights", "/api/bookings", "/api/dashboard",
"/api/airports", "/api/airlines",
} {
t.Run(path, func(t *testing.T) {
// No credentials and no tenant: the tenant check fires first.
rec := a.anon(t, http.MethodGet, path)
if rec.Code != http.StatusBadRequest && rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 400 or 401", rec.Code)
}
})
}
}
// --- session ---------------------------------------------------------------
func TestSessionDescribesTheCaller(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/session", nil)
requireStatus(t, rec, http.StatusOK)
got := decode[struct {
User models.User `json:"user"`
Tenant models.Tenant `json:"tenant"`
}](t, rec)
if got.User.Email != a.Agent.Email {
t.Errorf("user = %s, want %s", got.User.Email, a.Agent.Email)
}
if got.Tenant.Slug != a.Tenant.Slug {
t.Errorf("tenant = %s, want %s", got.Tenant.Slug, a.Tenant.Slug)
}
}
func TestSessionNeverReturnsThePasswordHash(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/session", nil)
requireStatus(t, rec, http.StatusOK)
if bytes.Contains(rec.Body.Bytes(), []byte("password")) {
t.Errorf("session response mentions a password field: %s", rec.Body)
}
}
// --- flights ---------------------------------------------------------------
func TestSearchFlightsReturnsAPage(t *testing.T) {
a := newAPI(t)
a.NewFlight(t, testsupport.FlightOpts{Origin: "JFK", Destination: "LHR"})
rec := a.do(t, http.MethodGet, "/api/flights?origin=JFK&destination=LHR", nil)
requireStatus(t, rec, http.StatusOK)
got := decode[struct {
Items []models.Flight `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}](t, rec)
if got.Total != 1 || len(got.Items) != 1 {
t.Fatalf("want one flight, got %d (total %d)", len(got.Items), got.Total)
}
if got.Limit != 25 {
t.Errorf("default limit = %d, want 25", got.Limit)
}
if got.Items[0].Airline.IATACode == "" || got.Items[0].Origin.City == "" {
t.Error("flight should arrive with its airline and airports expanded")
}
}
func TestSearchFlightsValidatesInput(t *testing.T) {
a := newAPI(t)
cases := []struct {
name string
path string
want int
}{
{"bad date", "/api/flights?date=12-09-2026", http.StatusBadRequest},
{"bad cabin", "/api/flights?cabin=luxury", http.StatusBadRequest},
{"good cabin", "/api/flights?cabin=business", http.StatusOK},
{"absurd limit is clamped", "/api/flights?limit=99999", http.StatusOK},
{"negative offset falls back", "/api/flights?offset=-5", http.StatusOK},
{"non-numeric passengers falls back", "/api/flights?passengers=lots", http.StatusOK},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := a.do(t, http.MethodGet, tc.path, nil)
if rec.Code != tc.want {
t.Errorf("status = %d, want %d (body: %s)", rec.Code, tc.want, rec.Body)
}
})
}
}
func TestGetFlightRejectsAMalformedID(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/flights/not-a-uuid", nil)
requireStatus(t, rec, http.StatusBadRequest)
}
func TestGetFlightAcrossTenantsIsFourOhFour(t *testing.T) {
a := newAPI(t)
other := testsupport.Setup(t)
theirFlight := other.NewFlight(t, testsupport.FlightOpts{})
rec := a.do(t, http.MethodGet, "/api/flights/"+theirFlight.ID.String(), nil)
requireStatus(t, rec, http.StatusNotFound)
}
// --- bookings --------------------------------------------------------------
func newBooking(flightID uuid.UUID, n int) models.CreateBookingRequest {
req := models.CreateBookingRequest{
FlightID: flightID,
ContactEmail: "ops@example.com",
ContactPhone: "+1-555-0100",
}
for i := 0; i < n; i++ {
req.Passengers = append(req.Passengers, models.PassengerInput{
FirstName: "Test", LastName: "Traveller",
})
}
return req
}
func TestCreateBookingReturnsCreated(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20, Price: 300})
rec := a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 2))
requireStatus(t, rec, http.StatusCreated)
got := decode[models.Booking](t, rec)
if got.TotalAmount != 600 {
t.Errorf("TotalAmount = %v, want 600", got.TotalAmount)
}
if len(got.Passengers) != 2 {
t.Errorf("got %d passengers, want 2", len(got.Passengers))
}
if left := a.SeatsAvailable(t, flight.ID); left != 18 {
t.Errorf("seats = %d, want 18", left)
}
}
func TestCreateBookingValidation(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20})
noName := newBooking(flight.ID, 1)
noName.Passengers[0].FirstName = " "
tooMany := newBooking(flight.ID, 10)
cases := []struct {
name string
body models.CreateBookingRequest
}{
{"missing flight id", models.CreateBookingRequest{
ContactEmail: "a@b.co",
Passengers: []models.PassengerInput{{FirstName: "A", LastName: "B"}},
}},
{"missing contact email", models.CreateBookingRequest{
FlightID: flight.ID,
Passengers: []models.PassengerInput{{FirstName: "A", LastName: "B"}},
}},
{"malformed contact email", models.CreateBookingRequest{
FlightID: flight.ID,
ContactEmail: "definitely-not-an-email",
Passengers: []models.PassengerInput{{FirstName: "A", LastName: "B"}},
}},
{"no passengers", models.CreateBookingRequest{
FlightID: flight.ID, ContactEmail: "a@b.co",
}},
{"blank passenger name", noName},
{"more than nine passengers", tooMany},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := a.do(t, http.MethodPost, "/api/bookings", tc.body)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 (body: %s)", rec.Code, rec.Body)
}
})
}
if left := a.SeatsAvailable(t, flight.ID); left != 20 {
t.Errorf("rejected bookings consumed seats: %d left, want 20", left)
}
}
func TestCreateBookingRejectsUnknownFields(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20})
// A client trying to name its own tenant must not be quietly ignored.
body := map[string]any{
"flightId": flight.ID,
"contactEmail": "ops@example.com",
"tenantId": uuid.New(),
"passengers": []map[string]string{{"firstName": "A", "lastName": "B"}},
}
rec := a.do(t, http.MethodPost, "/api/bookings", body)
requireStatus(t, rec, http.StatusBadRequest)
}
func TestCreateBookingRejectsMalformedJSON(t *testing.T) {
a := newAPI(t)
req := httptest.NewRequest(http.MethodPost, "/api/bookings", bytes.NewReader([]byte("{not json")))
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString(
[]byte(a.Agent.Email+":"+testsupport.Password)))
req.Header.Set(middleware.TenantHeader, a.Tenant.Slug)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
a.handler.ServeHTTP(rec, req)
requireStatus(t, rec, http.StatusBadRequest)
}
func TestCreateBookingOverCapacityIsConflict(t *testing.T) {
a := newAPI(t)
two := 2
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 10, Available: &two})
rec := a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 3))
requireStatus(t, rec, http.StatusConflict)
}
func TestCreateBookingOnAnotherTenantsFlightIsFourOhFour(t *testing.T) {
a := newAPI(t)
other := testsupport.Setup(t)
theirFlight := other.NewFlight(t, testsupport.FlightOpts{Seats: 50})
rec := a.do(t, http.MethodPost, "/api/bookings", newBooking(theirFlight.ID, 1))
requireStatus(t, rec, http.StatusNotFound)
}
func TestBookingLifecycleOverHTTP(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20, Price: 100})
created := decode[models.Booking](t, a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 2)))
fetched := decode[models.Booking](t, a.do(t, http.MethodGet, "/api/bookings/"+created.ID.String(), nil))
if fetched.BookingReference != created.BookingReference {
t.Errorf("fetched a different booking: %s vs %s", fetched.BookingReference, created.BookingReference)
}
listed := decode[struct {
Items []models.Booking `json:"items"`
Total int `json:"total"`
}](t, a.do(t, http.MethodGet, "/api/bookings", nil))
if listed.Total != 1 {
t.Errorf("list total = %d, want 1", listed.Total)
}
cancelled := decode[models.Booking](t,
a.do(t, http.MethodPost, "/api/bookings/"+created.ID.String()+"/cancel", nil))
if cancelled.Status != "cancelled" {
t.Errorf("status = %q, want cancelled", cancelled.Status)
}
if left := a.SeatsAvailable(t, flight.ID); left != 20 {
t.Errorf("seats after cancel = %d, want 20", left)
}
// A second cancel finds nothing to cancel.
again := a.do(t, http.MethodPost, "/api/bookings/"+created.ID.String()+"/cancel", nil)
requireStatus(t, again, http.StatusNotFound)
}
func TestListBookingsRejectsAnUnknownStatus(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/bookings?status=pending", nil)
requireStatus(t, rec, http.StatusBadRequest)
}
func TestBookingsAreInvisibleToOtherTenants(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20})
created := decode[models.Booking](t, a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 1)))
// A second agency, with valid credentials of its own, must see nothing.
other := testsupport.Setup(t)
rec := a.as(t, other.Agent.Email, testsupport.Password, other.Tenant.Slug,
http.MethodGet, "/api/bookings/"+created.ID.String(), nil)
requireStatus(t, rec, http.StatusNotFound)
list := decode[struct {
Total int `json:"total"`
}](t, a.as(t, other.Agent.Email, testsupport.Password, other.Tenant.Slug,
http.MethodGet, "/api/bookings", nil))
if list.Total != 0 {
t.Errorf("other tenant sees %d bookings, want 0", list.Total)
}
}
// --- dashboard and reference ----------------------------------------------
func TestDashboardSummarisesTheTenant(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20, Price: 250})
a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 2))
rec := a.do(t, http.MethodGet, "/api/dashboard", nil)
requireStatus(t, rec, http.StatusOK)
got := decode[models.DashboardStats](t, rec)
if got.ConfirmedBookings != 1 {
t.Errorf("ConfirmedBookings = %d, want 1", got.ConfirmedBookings)
}
if got.TotalRevenue != 500 {
t.Errorf("TotalRevenue = %v, want 500", got.TotalRevenue)
}
if len(got.RecentBookings) != 1 {
t.Errorf("RecentBookings = %d, want 1", len(got.RecentBookings))
}
}
func TestReferenceListsAreScopedToTheTenantNetwork(t *testing.T) {
a := newAPI(t)
a.NewFlight(t, testsupport.FlightOpts{Origin: "JFK", Destination: "LHR"})
airports := decode[[]models.Airport](t, a.do(t, http.MethodGet, "/api/airports", nil))
if len(airports) != 2 {
t.Errorf("got %d airports, want the 2 this tenant flies", len(airports))
}
airlines := decode[[]models.Airline](t, a.do(t, http.MethodGet, "/api/airlines", nil))
if len(airlines) != 1 {
t.Errorf("got %d airlines, want 1", len(airlines))
}
}
// --- routing ---------------------------------------------------------------
func TestUnknownRouteAndWrongMethod(t *testing.T) {
a := newAPI(t)
t.Run("unknown path", func(t *testing.T) {
rec := a.do(t, http.MethodGet, "/api/nonexistent", nil)
requireStatus(t, rec, http.StatusNotFound)
})
t.Run("wrong method", func(t *testing.T) {
rec := a.do(t, http.MethodDelete, "/api/flights", nil)
requireStatus(t, rec, http.StatusMethodNotAllowed)
})
}
func TestEveryJSONResponseCarriesItsContentType(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/flights", nil)
if got := rec.Header().Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q, want application/json", got)
}
}
+44
View File
@@ -0,0 +1,44 @@
package handlers
import (
"net/http"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// ListTenants backs the agency picker on the sign-in screen. It is
// unauthenticated by necessity -- the client must choose a tenant before it
// can present credentials -- so it returns nothing but slugs and display
// names.
func (h *Handler) ListTenants(w http.ResponseWriter, r *http.Request) {
tenants, err := h.Repo.ListTenants(r.Context())
if err != nil {
h.fail(w, err, "list tenants")
return
}
type publicTenant struct {
Slug string `json:"slug"`
Name string `json:"name"`
}
items := make([]publicTenant, 0, len(tenants))
for _, t := range tenants {
items = append(items, publicTenant{Slug: t.Slug, Name: t.Name})
}
writeJSON(w, http.StatusOK, items)
}
// Session echoes the authenticated caller back to the client. The Angular app
// calls it once at sign-in to validate the credentials it just collected, and
// again on reload to confirm the stored ones still work.
func (h *Handler) Session(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, struct {
User models.User `json:"user"`
Tenant models.Tenant `json:"tenant"`
}{User: id.User, Tenant: id.Tenant})
}
+55
View File
@@ -0,0 +1,55 @@
// Package httpapi assembles the HTTP handler tree.
//
// It lives outside package main so tests can drive the exact same routing,
// middleware order and auth rules the server runs in production, rather than a
// hand-rolled approximation of them.
package httpapi
import (
"log/slog"
"net/http"
"github.com/travelswitch/travelhub/backend/internal/handlers"
"github.com/travelswitch/travelhub/backend/internal/middleware"
"github.com/travelswitch/travelhub/backend/internal/repository"
)
// New builds the full handler.
//
// Everything under /api/ requires Basic auth plus a tenant, except the two
// endpoints registered explicitly on the root mux: health, and the tenant list
// the sign-in screen needs before any credentials exist. More specific
// patterns win in Go's mux, so those two bypass the authenticated subtree.
func New(
h *handlers.Handler,
repo *repository.Repository,
logger *slog.Logger,
corsOrigins []string,
) http.Handler {
protected := http.NewServeMux()
protected.HandleFunc("GET /api/session", h.Session)
protected.HandleFunc("GET /api/flights", h.SearchFlights)
protected.HandleFunc("GET /api/flights/{id}", h.GetFlight)
protected.HandleFunc("GET /api/airports", h.ListAirports)
protected.HandleFunc("GET /api/airlines", h.ListAirlines)
protected.HandleFunc("GET /api/bookings", h.ListBookings)
protected.HandleFunc("POST /api/bookings", h.CreateBooking)
protected.HandleFunc("GET /api/bookings/{id}", h.GetBooking)
protected.HandleFunc("POST /api/bookings/{id}/cancel", h.CancelBooking)
protected.HandleFunc("GET /api/dashboard", h.Dashboard)
root := http.NewServeMux()
root.HandleFunc("GET /api/health", h.Health)
root.HandleFunc("GET /api/tenants", h.ListTenants)
root.Handle("/api/", middleware.BasicAuth(repo, logger)(protected))
return middleware.Chain(root,
middleware.Recoverer(logger),
middleware.Logger(logger),
middleware.CORS(corsOrigins),
)
}
+223
View File
@@ -0,0 +1,223 @@
// Package middleware contains the HTTP layer that runs before handlers:
// panic recovery, logging, CORS, and Basic authentication with tenant
// resolution.
package middleware
import (
"crypto/subtle"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/travelswitch/travelhub/backend/internal/auth"
"github.com/travelswitch/travelhub/backend/internal/repository"
)
// TenantHeader names the header an SPA uses to say which agency it is signing
// in to. A subdomain is used as a fallback so the same build works when
// deployed as skyline.example.com.
const TenantHeader = "X-Tenant"
// Recoverer turns a panic into a 500 instead of killing the connection.
func Recoverer(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
logger.Error("panic serving request",
"method", r.Method, "path", r.URL.Path, "panic", rec)
writeError(w, http.StatusInternalServerError, "internal server error")
}
}()
next.ServeHTTP(w, r)
})
}
}
// statusRecorder remembers the status code so the logger can report it.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
// Logger writes one line per request.
func Logger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
logger.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"tenant", r.Header.Get(TenantHeader),
"duration", time.Since(start).String(),
)
})
}
}
// CORS answers preflights and echoes back only origins on the allow list.
func CORS(allowed []string) func(http.Handler) http.Handler {
allowSet := make(map[string]bool, len(allowed))
for _, o := range allowed {
allowSet[strings.TrimSuffix(o, "/")] = true
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := strings.TrimSuffix(r.Header.Get("Origin"), "/")
if origin != "" && allowSet[origin] {
h := w.Header()
h.Set("Access-Control-Allow-Origin", origin)
h.Set("Access-Control-Allow-Credentials", "true")
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type, "+TenantHeader)
h.Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
h.Set("Access-Control-Max-Age", "600")
// Responses differ per origin, so caches must key on it.
h.Add("Vary", "Origin")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
// BasicAuth authenticates the caller and pins them to one tenant.
//
// Both halves matter: the password is checked against the user row *inside*
// the named tenant, so identical credentials at another agency will not
// authenticate here. The resolved tenant goes into the request context and is
// the only tenant id the handlers below ever use.
func BasicAuth(repo *repository.Repository, logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
slug := tenantSlug(r)
if slug == "" {
writeError(w, http.StatusBadRequest,
"missing tenant: send the "+TenantHeader+" header")
return
}
email, password, ok := r.BasicAuth()
if !ok {
unauthorized(w)
return
}
ctx := r.Context()
tenant, err := repo.TenantBySlug(ctx, slug)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
// Same response as a bad password: do not let an
// unauthenticated caller enumerate which tenants exist.
unauthorized(w)
return
}
logger.Error("tenant lookup failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
user, err := repo.UserByEmail(ctx, tenant.ID, email)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
// Hash a throwaway value so a missing user costs the same
// time as a wrong password, which stops an attacker
// learning valid addresses from response timing.
bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
unauthorized(w)
return
}
logger.Error("user lookup failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
unauthorized(w)
return
}
// Defence in depth: the user row must belong to the tenant we
// resolved. The query already guarantees it; this catches a future
// refactor that breaks the invariant.
if subtle.ConstantTimeCompare(user.TenantID[:], tenant.ID[:]) != 1 {
unauthorized(w)
return
}
ctx = auth.WithIdentity(ctx, auth.Identity{User: user, Tenant: tenant})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// RequireAdmin gates routes that only a tenant administrator may call.
func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id, err := auth.FromContext(r.Context())
if err != nil || !id.IsAdmin() {
writeError(w, http.StatusForbidden, "administrator role required")
return
}
next.ServeHTTP(w, r)
})
}
// dummyHash is a valid bcrypt hash of a value nobody uses, for timing parity.
var dummyHash = []byte("$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy")
// tenantSlug reads the tenant from the header, falling back to the first label
// of the Host name for subdomain-per-tenant deployments.
func tenantSlug(r *http.Request) string {
if v := strings.TrimSpace(r.Header.Get(TenantHeader)); v != "" {
return strings.ToLower(v)
}
host := r.Host
if h, _, found := strings.Cut(host, ":"); found {
host = h
}
labels := strings.Split(host, ".")
if len(labels) >= 3 && labels[0] != "www" {
return strings.ToLower(labels[0])
}
return ""
}
// unauthorized replies 401 without a WWW-Authenticate header on purpose: that
// header makes browsers open their own credential dialog, which would fight
// with the Angular sign-in screen.
func unauthorized(w http.ResponseWriter) {
writeError(w, http.StatusUnauthorized, "invalid credentials for this tenant")
}
func writeError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": message})
}
// Chain applies middleware so the first argument is the outermost layer.
func Chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
for i := len(mw) - 1; i >= 0; i-- {
h = mw[i](h)
}
return h
}
@@ -0,0 +1,342 @@
package middleware_test
import (
"encoding/base64"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/travelswitch/travelhub/backend/internal/auth"
"github.com/travelswitch/travelhub/backend/internal/middleware"
"github.com/travelswitch/travelhub/backend/internal/testsupport"
)
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func basic(email, password string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(email+":"+password))
}
// guarded wraps a handler that records the identity the middleware resolved.
func guarded(f *testsupport.Fixture, seen *auth.Identity) http.Handler {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if id, err := auth.FromContext(r.Context()); err == nil && seen != nil {
*seen = id
}
w.WriteHeader(http.StatusOK)
})
return middleware.BasicAuth(f.Repo, quietLogger())(inner)
}
func TestBasicAuthAcceptsValidCredentials(t *testing.T) {
f := testsupport.Setup(t)
var got auth.Identity
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
req.Header.Set("Authorization", basic(f.Agent.Email, testsupport.Password))
req.Header.Set(middleware.TenantHeader, f.Tenant.Slug)
rec := httptest.NewRecorder()
guarded(f, &got).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body)
}
if got.User.ID != f.Agent.ID {
t.Errorf("resolved user %s, want %s", got.User.ID, f.Agent.ID)
}
if got.Tenant.ID != f.Tenant.ID {
t.Errorf("resolved tenant %s, want %s", got.Tenant.ID, f.Tenant.ID)
}
}
func TestBasicAuthIsCaseInsensitiveOnEmail(t *testing.T) {
f := testsupport.Setup(t)
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
req.Header.Set("Authorization", basic(strings.ToUpper(f.Agent.Email), testsupport.Password))
req.Header.Set(middleware.TenantHeader, f.Tenant.Slug)
rec := httptest.NewRecorder()
guarded(f, nil).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
}
func TestBasicAuthRejections(t *testing.T) {
f := testsupport.Setup(t)
other := testsupport.Setup(t)
cases := []struct {
name string
authHeader string
tenant string
wantStatus int
}{
{
name: "no credentials at all",
tenant: f.Tenant.Slug,
wantStatus: http.StatusUnauthorized,
},
{
name: "wrong password",
authHeader: basic(f.Agent.Email, "not-the-password"),
tenant: f.Tenant.Slug,
wantStatus: http.StatusUnauthorized,
},
{
name: "unknown user",
authHeader: basic("nobody@nowhere.test", testsupport.Password),
tenant: f.Tenant.Slug,
wantStatus: http.StatusUnauthorized,
},
{
name: "unknown tenant",
authHeader: basic(f.Agent.Email, testsupport.Password),
tenant: "no-such-agency",
wantStatus: http.StatusUnauthorized,
},
{
// The central rule: right password, wrong agency.
name: "valid credentials against another tenant",
authHeader: basic(f.Agent.Email, testsupport.Password),
tenant: other.Tenant.Slug,
wantStatus: http.StatusUnauthorized,
},
{
name: "missing tenant header",
authHeader: basic(f.Agent.Email, testsupport.Password),
tenant: "",
wantStatus: http.StatusBadRequest,
},
{
name: "malformed authorization header",
authHeader: "Bearer some.jwt.token",
tenant: f.Tenant.Slug,
wantStatus: http.StatusUnauthorized,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
if tc.authHeader != "" {
req.Header.Set("Authorization", tc.authHeader)
}
if tc.tenant != "" {
req.Header.Set(middleware.TenantHeader, tc.tenant)
}
rec := httptest.NewRecorder()
guarded(f, nil).ServeHTTP(rec, req)
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d (body: %s)", rec.Code, tc.wantStatus, rec.Body)
}
})
}
}
func TestBasicAuthRefusesSuspendedTenant(t *testing.T) {
f := testsupport.Setup(t)
f.Suspend(t)
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
req.Header.Set("Authorization", basic(f.Agent.Email, testsupport.Password))
req.Header.Set(middleware.TenantHeader, f.Tenant.Slug)
rec := httptest.NewRecorder()
guarded(f, nil).ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401 for a suspended tenant", rec.Code)
}
}
// Sending WWW-Authenticate would make the browser pop its own login dialog over
// the Angular sign-in screen, so its absence is a deliberate contract.
func TestUnauthorizedDoesNotTriggerTheBrowserDialog(t *testing.T) {
f := testsupport.Setup(t)
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
req.Header.Set("Authorization", basic(f.Agent.Email, "wrong"))
req.Header.Set(middleware.TenantHeader, f.Tenant.Slug)
rec := httptest.NewRecorder()
guarded(f, nil).ServeHTTP(rec, req)
if got := rec.Header().Get("WWW-Authenticate"); got != "" {
t.Errorf("WWW-Authenticate = %q, want it absent", got)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
}
// Deployments that give each agency a subdomain need no client changes.
func TestTenantFallsBackToSubdomain(t *testing.T) {
f := testsupport.Setup(t)
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
req.Header.Set("Authorization", basic(f.Agent.Email, testsupport.Password))
req.Host = f.Tenant.Slug + ".travelhub.example.com:8080"
rec := httptest.NewRecorder()
guarded(f, nil).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200 resolving the tenant from the subdomain", rec.Code)
}
}
func TestTenantHeaderBeatsSubdomain(t *testing.T) {
f := testsupport.Setup(t)
other := testsupport.Setup(t)
var got auth.Identity
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
req.Header.Set("Authorization", basic(f.Agent.Email, testsupport.Password))
req.Header.Set(middleware.TenantHeader, f.Tenant.Slug)
req.Host = other.Tenant.Slug + ".travelhub.example.com"
rec := httptest.NewRecorder()
guarded(f, &got).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got.Tenant.ID != f.Tenant.ID {
t.Error("the subdomain overrode the explicit tenant header")
}
}
func TestRequireAdmin(t *testing.T) {
f := testsupport.Setup(t)
adminOnly := middleware.BasicAuth(f.Repo, quietLogger())(
middleware.RequireAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})),
)
cases := []struct {
name string
email string
want int
}{
{"admin passes", f.Admin.Email, http.StatusOK},
{"agent is refused", f.Agent.Email, http.StatusForbidden},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/admin", nil)
req.Header.Set("Authorization", basic(tc.email, testsupport.Password))
req.Header.Set(middleware.TenantHeader, f.Tenant.Slug)
rec := httptest.NewRecorder()
adminOnly.ServeHTTP(rec, req)
if rec.Code != tc.want {
t.Errorf("status = %d, want %d", rec.Code, tc.want)
}
})
}
}
func TestCORS(t *testing.T) {
allowed := []string{"http://localhost:4200", "https://app.example.com/"}
ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := middleware.CORS(allowed)(ok)
t.Run("echoes an allowed origin", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/flights", nil)
req.Header.Set("Origin", "http://localhost:4200")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:4200" {
t.Errorf("Allow-Origin = %q", got)
}
if got := rec.Header().Get("Vary"); !strings.Contains(got, "Origin") {
t.Errorf("Vary = %q, want it to include Origin", got)
}
if got := rec.Header().Get("Access-Control-Allow-Headers"); !strings.Contains(got, middleware.TenantHeader) {
t.Errorf("Allow-Headers = %q, want it to include %s", got, middleware.TenantHeader)
}
})
t.Run("tolerates a trailing slash in configuration", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/flights", nil)
req.Header.Set("Origin", "https://app.example.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example.com" {
t.Errorf("Allow-Origin = %q, want the origin echoed", got)
}
})
t.Run("ignores an origin that is not on the list", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/flights", nil)
req.Header.Set("Origin", "https://evil.example.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("Allow-Origin = %q, want it absent", got)
}
})
t.Run("answers preflight without reaching the handler", func(t *testing.T) {
reached := false
h := middleware.CORS(allowed)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
reached = true
}))
req := httptest.NewRequest(http.MethodOptions, "/api/bookings", nil)
req.Header.Set("Origin", "http://localhost:4200")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Errorf("status = %d, want 204", rec.Code)
}
if reached {
t.Error("preflight should short-circuit before the handler")
}
})
}
func TestRecovererTurnsPanicIntoFiveHundred(t *testing.T) {
boom := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
panic("something went very wrong")
})
handler := middleware.Recoverer(quietLogger())(boom)
req := httptest.NewRequest(http.MethodGet, "/api/flights", nil)
rec := httptest.NewRecorder()
// The point is that this call returns rather than taking the process down.
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500", rec.Code)
}
if !strings.Contains(rec.Body.String(), "internal server error") {
t.Errorf("body = %s, want a JSON error", rec.Body)
}
}
+143
View File
@@ -0,0 +1,143 @@
// Package models holds the API-facing representations of TravelHub data.
// JSON field names are camelCase so the Angular client can consume them
// without a translation layer.
package models
import (
"time"
"github.com/google/uuid"
)
// Tenant is one travel agency using the platform.
type Tenant struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
}
// User belongs to exactly one tenant. Email is unique per tenant, not globally,
// so the same person can hold accounts at several agencies.
type User struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenantId"`
Email string `json:"email"`
FullName string `json:"fullName"`
Role string `json:"role"`
CreatedAt time.Time `json:"createdAt"`
// PasswordHash never leaves the server.
PasswordHash string `json:"-"`
}
// Airport is global reference data, shared by every tenant.
type Airport struct {
ID uuid.UUID `json:"id"`
IATACode string `json:"iataCode"`
Name string `json:"name"`
City string `json:"city"`
Country string `json:"country"`
Timezone string `json:"timezone"`
}
// Airline is global reference data, shared by every tenant.
type Airline struct {
ID uuid.UUID `json:"id"`
IATACode string `json:"iataCode"`
Name string `json:"name"`
LogoURL string `json:"logoUrl,omitempty"`
}
// Flight is tenant-scoped inventory: each agency loads its own fares, so the
// same physical route carries a different price per tenant.
type Flight struct {
ID uuid.UUID `json:"id"`
FlightNumber string `json:"flightNumber"`
Airline Airline `json:"airline"`
Origin Airport `json:"origin"`
Destination Airport `json:"destination"`
DepartureTime time.Time `json:"departureTime"`
ArrivalTime time.Time `json:"arrivalTime"`
DurationMinutes int `json:"durationMinutes"`
Aircraft string `json:"aircraft"`
CabinClass string `json:"cabinClass"`
BasePrice float64 `json:"basePrice"`
Currency string `json:"currency"`
SeatsTotal int `json:"seatsTotal"`
SeatsAvailable int `json:"seatsAvailable"`
Status string `json:"status"`
}
// Passenger is one traveller on a booking.
type Passenger struct {
ID uuid.UUID `json:"id"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
DateOfBirth *time.Time `json:"dateOfBirth,omitempty"`
PassportNumber string `json:"passportNumber,omitempty"`
SeatNumber string `json:"seatNumber,omitempty"`
}
// Booking is a confirmed sale against a flight, owned by one tenant.
type Booking struct {
ID uuid.UUID `json:"id"`
BookingReference string `json:"bookingReference"`
Flight Flight `json:"flight"`
Passengers []Passenger `json:"passengers"`
PassengerCount int `json:"passengerCount"`
TotalAmount float64 `json:"totalAmount"`
Currency string `json:"currency"`
Status string `json:"status"`
ContactEmail string `json:"contactEmail"`
ContactPhone string `json:"contactPhone,omitempty"`
BookedBy string `json:"bookedBy"`
CreatedAt time.Time `json:"createdAt"`
}
// FlightSearch carries the filters accepted by GET /api/flights.
type FlightSearch struct {
Origin string // IATA code, optional
Destination string // IATA code, optional
DepartDate *time.Time // matches the whole calendar day, optional
CabinClass string // optional
Passengers int // minimum seats that must still be available
SortBy string // price | duration | departure
Limit int
Offset int
}
// PassengerInput is one traveller as supplied by the client. Dates arrive as
// YYYY-MM-DD strings and are parsed when the booking is written.
type PassengerInput struct {
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
DateOfBirth string `json:"dateOfBirth"`
PassportNumber string `json:"passportNumber"`
}
// CreateBookingRequest is the POST /api/bookings payload.
type CreateBookingRequest struct {
FlightID uuid.UUID `json:"flightId"`
ContactEmail string `json:"contactEmail"`
ContactPhone string `json:"contactPhone"`
Passengers []PassengerInput `json:"passengers"`
}
// DashboardStats powers the agency overview screen.
type DashboardStats struct {
TotalBookings int `json:"totalBookings"`
ConfirmedBookings int `json:"confirmedBookings"`
CancelledBookings int `json:"cancelledBookings"`
TotalRevenue float64 `json:"totalRevenue"`
Currency string `json:"currency"`
FlightsInStock int `json:"flightsInStock"`
SeatsSold int `json:"seatsSold"`
RecentBookings []Booking `json:"recentBookings"`
TopRoutes []struct {
Route string `json:"route"`
Bookings int `json:"bookings"`
Revenue float64 `json:"revenue"`
} `json:"topRoutes"`
}
+330
View File
@@ -0,0 +1,330 @@
package repository
import (
"context"
"crypto/rand"
"errors"
"fmt"
"math/big"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// bookingSelect projects a booking together with its flight and the agent who
// sold it.
const bookingSelect = `
SELECT b.id, b.booking_reference, b.passenger_count, b.total_amount,
b.currency, b.status, b.contact_email, b.contact_phone, b.created_at,
u.full_name,
f.id, f.flight_number, f.departure_time, f.arrival_time, f.aircraft,
f.cabin_class, f.base_price, f.currency, f.seats_total,
f.seats_available, f.status,
al.id, al.iata_code, al.name, COALESCE(al.logo_url, ''),
o.id, o.iata_code, o.name, o.city, o.country, o.timezone,
d.id, d.iata_code, d.name, d.city, d.country, d.timezone
FROM bookings b
JOIN users u ON u.id = b.user_id
JOIN flights f ON f.id = b.flight_id
JOIN airlines al ON al.id = f.airline_id
JOIN airports o ON o.id = f.origin_airport_id
JOIN airports d ON d.id = f.destination_airport_id`
func scanBooking(row pgx.Row) (models.Booking, error) {
var b models.Booking
f := &b.Flight
err := row.Scan(
&b.ID, &b.BookingReference, &b.PassengerCount, &b.TotalAmount,
&b.Currency, &b.Status, &b.ContactEmail, &b.ContactPhone, &b.CreatedAt,
&b.BookedBy,
&f.ID, &f.FlightNumber, &f.DepartureTime, &f.ArrivalTime, &f.Aircraft,
&f.CabinClass, &f.BasePrice, &f.Currency, &f.SeatsTotal,
&f.SeatsAvailable, &f.Status,
&f.Airline.ID, &f.Airline.IATACode, &f.Airline.Name, &f.Airline.LogoURL,
&f.Origin.ID, &f.Origin.IATACode, &f.Origin.Name, &f.Origin.City,
&f.Origin.Country, &f.Origin.Timezone,
&f.Destination.ID, &f.Destination.IATACode, &f.Destination.Name,
&f.Destination.City, &f.Destination.Country, &f.Destination.Timezone,
)
if err != nil {
return models.Booking{}, err
}
f.DurationMinutes = int(f.ArrivalTime.Sub(f.DepartureTime).Minutes())
b.Passengers = []models.Passenger{}
return b, nil
}
// pnrAlphabet omits I, O, 0 and 1 because agents read references aloud.
const pnrAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
func newPNR() (string, error) {
out := make([]byte, 6)
for i := range out {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(pnrAlphabet))))
if err != nil {
return "", err
}
out[i] = pnrAlphabet[n.Int64()]
}
return string(out), nil
}
// CreateBooking sells seats on a flight.
//
// The whole thing runs in one transaction and takes a row lock on the flight
// (SELECT ... FOR UPDATE) before reading availability, so two agents booking
// the last seats at the same moment cannot oversell: the second waits for the
// first to commit and then sees the decremented count.
func (r *Repository) CreateBooking(ctx context.Context, tenantID, userID uuid.UUID, req models.CreateBookingRequest) (models.Booking, error) {
if len(req.Passengers) == 0 {
return models.Booking{}, errors.New("at least one passenger is required")
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return models.Booking{}, err
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
var (
price float64
currency string
available int
)
const lockSQL = `
SELECT base_price, currency, seats_available
FROM flights
WHERE tenant_id = $1 AND id = $2 AND status <> 'cancelled'
FOR UPDATE`
err = tx.QueryRow(ctx, lockSQL, tenantID, req.FlightID).Scan(&price, &currency, &available)
if errors.Is(err, pgx.ErrNoRows) {
return models.Booking{}, ErrNotFound
}
if err != nil {
return models.Booking{}, fmt.Errorf("lock flight: %w", err)
}
count := len(req.Passengers)
if available < count {
return models.Booking{}, ErrNoSeats
}
const decSQL = `UPDATE flights SET seats_available = seats_available - $3
WHERE tenant_id = $1 AND id = $2`
if _, err := tx.Exec(ctx, decSQL, tenantID, req.FlightID, count); err != nil {
return models.Booking{}, fmt.Errorf("reserve seats: %w", err)
}
const insertSQL = `
INSERT INTO bookings (tenant_id, user_id, flight_id, booking_reference,
passenger_count, total_amount, currency,
contact_email, contact_phone)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id`
// The PNR is unique per tenant; on the rare collision, draw again.
var bookingID uuid.UUID
for attempt := 0; attempt < 5; attempt++ {
pnr, perr := newPNR()
if perr != nil {
return models.Booking{}, perr
}
err = tx.QueryRow(ctx, insertSQL,
tenantID, userID, req.FlightID, pnr, count,
price*float64(count), currency, req.ContactEmail, req.ContactPhone,
).Scan(&bookingID)
if err == nil {
break
}
if !isUniqueViolation(err) {
return models.Booking{}, fmt.Errorf("insert booking: %w", err)
}
}
if err != nil {
return models.Booking{}, fmt.Errorf("insert booking: %w", err)
}
const paxSQL = `
INSERT INTO passengers (tenant_id, booking_id, first_name, last_name,
date_of_birth, passport_number)
VALUES ($1, $2, $3, $4, $5, $6)`
for _, p := range req.Passengers {
var dob *time.Time
if p.DateOfBirth != "" {
if parsed, perr := time.Parse("2006-01-02", p.DateOfBirth); perr == nil {
dob = &parsed
}
}
if _, err := tx.Exec(ctx, paxSQL, tenantID, bookingID,
p.FirstName, p.LastName, dob, p.PassportNumber); err != nil {
return models.Booking{}, fmt.Errorf("insert passenger: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return models.Booking{}, err
}
return r.BookingByID(ctx, tenantID, bookingID)
}
// BookingByID returns one booking with its passengers.
func (r *Repository) BookingByID(ctx context.Context, tenantID, bookingID uuid.UUID) (models.Booking, error) {
q := bookingSelect + ` WHERE b.tenant_id = $1 AND b.id = $2`
b, err := scanBooking(r.pool.QueryRow(ctx, q, tenantID, bookingID))
if errors.Is(err, pgx.ErrNoRows) {
return models.Booking{}, ErrNotFound
}
if err != nil {
return models.Booking{}, err
}
pax, err := r.passengersFor(ctx, tenantID, []uuid.UUID{b.ID})
if err != nil {
return models.Booking{}, err
}
if list, ok := pax[b.ID]; ok {
b.Passengers = list
}
return b, nil
}
// ListBookings returns a tenant's bookings, newest first, with passengers
// attached. Passengers are fetched in one extra query rather than one per
// booking.
func (r *Repository) ListBookings(ctx context.Context, tenantID uuid.UUID, status string, limit, offset int) ([]models.Booking, int, error) {
where := "WHERE b.tenant_id = $1"
args := []any{tenantID}
if status != "" {
args = append(args, status)
where += fmt.Sprintf(" AND b.status = $%d", len(args))
}
var total int
if err := r.pool.QueryRow(ctx, "SELECT count(*) FROM bookings b "+where, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count bookings: %w", err)
}
if limit <= 0 || limit > 100 {
limit = 25
}
if offset < 0 {
offset = 0
}
q := fmt.Sprintf("%s %s ORDER BY b.created_at DESC LIMIT $%d OFFSET $%d",
bookingSelect, where, len(args)+1, len(args)+2)
args = append(args, limit, offset)
rows, err := r.pool.Query(ctx, q, args...)
if err != nil {
return nil, 0, fmt.Errorf("list bookings: %w", err)
}
defer rows.Close()
bookings := []models.Booking{}
ids := []uuid.UUID{}
for rows.Next() {
b, err := scanBooking(rows)
if err != nil {
return nil, 0, err
}
bookings = append(bookings, b)
ids = append(ids, b.ID)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
if len(ids) > 0 {
pax, err := r.passengersFor(ctx, tenantID, ids)
if err != nil {
return nil, 0, err
}
for i := range bookings {
if list, ok := pax[bookings[i].ID]; ok {
bookings[i].Passengers = list
}
}
}
return bookings, total, nil
}
// CancelBooking voids a booking and returns its seats to inventory.
func (r *Repository) CancelBooking(ctx context.Context, tenantID, bookingID uuid.UUID) (models.Booking, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return models.Booking{}, err
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
var (
flightID uuid.UUID
count int
)
// Filtering on status here makes the cancel idempotent-safe: a second
// cancel matches no row and returns ErrNotFound rather than crediting the
// seats twice.
const claimSQL = `
UPDATE bookings SET status = 'cancelled'
WHERE tenant_id = $1 AND id = $2 AND status = 'confirmed'
RETURNING flight_id, passenger_count`
err = tx.QueryRow(ctx, claimSQL, tenantID, bookingID).Scan(&flightID, &count)
if errors.Is(err, pgx.ErrNoRows) {
return models.Booking{}, ErrNotFound
}
if err != nil {
return models.Booking{}, fmt.Errorf("cancel booking: %w", err)
}
const restoreSQL = `
UPDATE flights
SET seats_available = LEAST(seats_total, seats_available + $3)
WHERE tenant_id = $1 AND id = $2`
if _, err := tx.Exec(ctx, restoreSQL, tenantID, flightID, count); err != nil {
return models.Booking{}, fmt.Errorf("restore seats: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return models.Booking{}, err
}
return r.BookingByID(ctx, tenantID, bookingID)
}
// passengersFor loads passengers for several bookings at once, keyed by
// booking id.
func (r *Repository) passengersFor(ctx context.Context, tenantID uuid.UUID, bookingIDs []uuid.UUID) (map[uuid.UUID][]models.Passenger, error) {
const q = `
SELECT booking_id, id, first_name, last_name, date_of_birth,
passport_number, seat_number
FROM passengers
WHERE tenant_id = $1 AND booking_id = ANY($2)
ORDER BY last_name, first_name`
rows, err := r.pool.Query(ctx, q, tenantID, bookingIDs)
if err != nil {
return nil, fmt.Errorf("list passengers: %w", err)
}
defer rows.Close()
out := map[uuid.UUID][]models.Passenger{}
for rows.Next() {
var (
bookingID uuid.UUID
p models.Passenger
)
if err := rows.Scan(&bookingID, &p.ID, &p.FirstName, &p.LastName,
&p.DateOfBirth, &p.PassportNumber, &p.SeatNumber); err != nil {
return nil, err
}
out[bookingID] = append(out[bookingID], p)
}
return out, rows.Err()
}
@@ -0,0 +1,415 @@
package repository_test
import (
"context"
"sync"
"testing"
"github.com/google/uuid"
"github.com/travelswitch/travelhub/backend/internal/models"
"github.com/travelswitch/travelhub/backend/internal/repository"
"github.com/travelswitch/travelhub/backend/internal/testsupport"
)
// booking builds a request for the given flight with n generated travellers.
func booking(flightID uuid.UUID, n int) models.CreateBookingRequest {
req := models.CreateBookingRequest{
FlightID: flightID,
ContactEmail: "ops@example.com",
ContactPhone: "+1-555-0100",
}
names := []string{"Ada", "Ben", "Cleo", "Dara", "Emil", "Fay", "Gus", "Hana", "Ivo"}
for i := 0; i < n; i++ {
req.Passengers = append(req.Passengers, models.PassengerInput{
FirstName: names[i%len(names)],
LastName: "Traveller",
DateOfBirth: "1990-01-15",
PassportNumber: "P000000" + string(rune('1'+i)),
})
}
return req
}
func TestCreateBookingSellsSeats(t *testing.T) {
f := testsupport.Setup(t)
ctx := context.Background()
flight := f.NewFlight(t, testsupport.FlightOpts{Seats: 50, Price: 250})
got, err := f.Repo.CreateBooking(ctx, f.Tenant.ID, f.Agent.ID, booking(flight.ID, 2))
if err != nil {
t.Fatalf("create booking: %v", err)
}
if got.PassengerCount != 2 {
t.Errorf("PassengerCount = %d, want 2", got.PassengerCount)
}
if got.TotalAmount != 500 {
t.Errorf("TotalAmount = %v, want 500 (250 x 2)", got.TotalAmount)
}
if got.Status != "confirmed" {
t.Errorf("Status = %q, want confirmed", got.Status)
}
if len(got.BookingReference) != 6 {
t.Errorf("booking reference %q should be 6 characters", got.BookingReference)
}
if len(got.Passengers) != 2 {
t.Errorf("got %d passenger rows, want 2", len(got.Passengers))
}
if got.BookedBy != f.Agent.FullName {
t.Errorf("BookedBy = %q, want %q", got.BookedBy, f.Agent.FullName)
}
if left := f.SeatsAvailable(t, flight.ID); left != 48 {
t.Errorf("seats_available = %d, want 48", left)
}
}
func TestCreateBookingStoresPassengerDetail(t *testing.T) {
f := testsupport.Setup(t)
flight := f.NewFlight(t, testsupport.FlightOpts{})
got, err := f.Repo.CreateBooking(context.Background(), f.Tenant.ID, f.Agent.ID, booking(flight.ID, 1))
if err != nil {
t.Fatalf("create booking: %v", err)
}
p := got.Passengers[0]
if p.FirstName != "Ada" || p.LastName != "Traveller" {
t.Errorf("passenger name = %s %s, want Ada Traveller", p.FirstName, p.LastName)
}
if p.DateOfBirth == nil {
t.Fatal("date of birth was not parsed and stored")
}
if got := p.DateOfBirth.Format("2006-01-02"); got != "1990-01-15" {
t.Errorf("date of birth = %s, want 1990-01-15", got)
}
}
func TestCreateBookingRejectsBlankDateOfBirth(t *testing.T) {
f := testsupport.Setup(t)
flight := f.NewFlight(t, testsupport.FlightOpts{})
req := booking(flight.ID, 1)
req.Passengers[0].DateOfBirth = "" // optional field left empty by the UI
got, err := f.Repo.CreateBooking(context.Background(), f.Tenant.ID, f.Agent.ID, req)
if err != nil {
t.Fatalf("an empty date of birth should be accepted: %v", err)
}
if got.Passengers[0].DateOfBirth != nil {
t.Error("want nil date of birth when none was supplied")
}
}
func TestCreateBookingRefusesMoreSeatsThanRemain(t *testing.T) {
f := testsupport.Setup(t)
two := 2
flight := f.NewFlight(t, testsupport.FlightOpts{Seats: 10, Available: &two})
_, err := f.Repo.CreateBooking(context.Background(), f.Tenant.ID, f.Agent.ID, booking(flight.ID, 3))
if err != repository.ErrNoSeats {
t.Fatalf("want ErrNoSeats, got %v", err)
}
if left := f.SeatsAvailable(t, flight.ID); left != 2 {
t.Errorf("a refused booking must not consume seats: got %d, want 2", left)
}
}
func TestCreateBookingRequiresAPassenger(t *testing.T) {
f := testsupport.Setup(t)
flight := f.NewFlight(t, testsupport.FlightOpts{})
req := models.CreateBookingRequest{FlightID: flight.ID, ContactEmail: "a@b.co"}
if _, err := f.Repo.CreateBooking(context.Background(), f.Tenant.ID, f.Agent.ID, req); err == nil {
t.Fatal("want an error for a booking with no passengers")
}
}
func TestCreateBookingCannotReachAnotherTenantsFlight(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
other := theirs.NewFlight(t, testsupport.FlightOpts{Seats: 100})
_, err := mine.Repo.CreateBooking(context.Background(), mine.Tenant.ID, mine.Agent.ID,
booking(other.ID, 1))
if err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound booking across tenants, got %v", err)
}
if left := theirs.SeatsAvailable(t, other.ID); left != 100 {
t.Errorf("the other tenant's inventory was touched: %d seats left, want 100", left)
}
}
func TestCreateBookingRefusesCancelledFlight(t *testing.T) {
f := testsupport.Setup(t)
flight := f.NewFlight(t, testsupport.FlightOpts{Status: "cancelled"})
_, err := f.Repo.CreateBooking(context.Background(), f.Tenant.ID, f.Agent.ID, booking(flight.ID, 1))
if err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound for a cancelled flight, got %v", err)
}
}
// The row lock is the only thing standing between two simultaneous sales and
// an oversold flight, so assert on it directly.
func TestCreateBookingDoesNotOversellUnderConcurrency(t *testing.T) {
f := testsupport.Setup(t)
ctx := context.Background()
const contenders = 8
one := 1
flight := f.NewFlight(t, testsupport.FlightOpts{Seats: 20, Available: &one})
var (
wg sync.WaitGroup
mu sync.Mutex
sold int
refused int
unexpect []error
)
start := make(chan struct{})
for i := 0; i < contenders; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start // release everyone at once
_, err := f.Repo.CreateBooking(ctx, f.Tenant.ID, f.Agent.ID, booking(flight.ID, 1))
mu.Lock()
defer mu.Unlock()
switch {
case err == nil:
sold++
case err == repository.ErrNoSeats:
refused++
default:
unexpect = append(unexpect, err)
}
}()
}
close(start)
wg.Wait()
if len(unexpect) > 0 {
t.Fatalf("unexpected errors: %v", unexpect)
}
if sold != 1 {
t.Errorf("exactly one booking should succeed, got %d", sold)
}
if refused != contenders-1 {
t.Errorf("got %d refusals, want %d", refused, contenders-1)
}
if left := f.SeatsAvailable(t, flight.ID); left != 0 {
t.Errorf("seats_available = %d, want 0", left)
}
}
func TestCancelBookingReturnsSeats(t *testing.T) {
f := testsupport.Setup(t)
ctx := context.Background()
flight := f.NewFlight(t, testsupport.FlightOpts{Seats: 30})
made, err := f.Repo.CreateBooking(ctx, f.Tenant.ID, f.Agent.ID, booking(flight.ID, 3))
if err != nil {
t.Fatalf("create booking: %v", err)
}
if left := f.SeatsAvailable(t, flight.ID); left != 27 {
t.Fatalf("seats after booking = %d, want 27", left)
}
got, err := f.Repo.CancelBooking(ctx, f.Tenant.ID, made.ID)
if err != nil {
t.Fatalf("cancel: %v", err)
}
if got.Status != "cancelled" {
t.Errorf("Status = %q, want cancelled", got.Status)
}
if left := f.SeatsAvailable(t, flight.ID); left != 30 {
t.Errorf("seats after cancel = %d, want 30", left)
}
}
// Cancelling twice must not credit the seats twice.
func TestCancelBookingIsNotRepeatable(t *testing.T) {
f := testsupport.Setup(t)
ctx := context.Background()
flight := f.NewFlight(t, testsupport.FlightOpts{Seats: 30})
made, err := f.Repo.CreateBooking(ctx, f.Tenant.ID, f.Agent.ID, booking(flight.ID, 2))
if err != nil {
t.Fatalf("create booking: %v", err)
}
if _, err := f.Repo.CancelBooking(ctx, f.Tenant.ID, made.ID); err != nil {
t.Fatalf("first cancel: %v", err)
}
if _, err := f.Repo.CancelBooking(ctx, f.Tenant.ID, made.ID); err != repository.ErrNotFound {
t.Fatalf("second cancel should be ErrNotFound, got %v", err)
}
if left := f.SeatsAvailable(t, flight.ID); left != 30 {
t.Errorf("seats = %d, want 30 -- a second cancel must not credit again", left)
}
}
func TestCancelBookingAcrossTenantsIsNotFound(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
ctx := context.Background()
flight := theirs.NewFlight(t, testsupport.FlightOpts{Seats: 10})
made, err := theirs.Repo.CreateBooking(ctx, theirs.Tenant.ID, theirs.Agent.ID, booking(flight.ID, 1))
if err != nil {
t.Fatalf("create booking: %v", err)
}
if _, err := mine.Repo.CancelBooking(ctx, mine.Tenant.ID, made.ID); err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound, got %v", err)
}
still, err := theirs.Repo.BookingByID(ctx, theirs.Tenant.ID, made.ID)
if err != nil {
t.Fatalf("read back: %v", err)
}
if still.Status != "confirmed" {
t.Error("another tenant managed to cancel this booking")
}
}
func TestListBookingsIsolatesTenantsAndFiltersStatus(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
ctx := context.Background()
flight := mine.NewFlight(t, testsupport.FlightOpts{Seats: 50})
keep, err := mine.Repo.CreateBooking(ctx, mine.Tenant.ID, mine.Agent.ID, booking(flight.ID, 1))
if err != nil {
t.Fatalf("create: %v", err)
}
drop, err := mine.Repo.CreateBooking(ctx, mine.Tenant.ID, mine.Agent.ID, booking(flight.ID, 1))
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := mine.Repo.CancelBooking(ctx, mine.Tenant.ID, drop.ID); err != nil {
t.Fatalf("cancel: %v", err)
}
otherFlight := theirs.NewFlight(t, testsupport.FlightOpts{Seats: 50})
if _, err := theirs.Repo.CreateBooking(ctx, theirs.Tenant.ID, theirs.Agent.ID, booking(otherFlight.ID, 1)); err != nil {
t.Fatalf("create for other tenant: %v", err)
}
all, total, err := mine.Repo.ListBookings(ctx, mine.Tenant.ID, "", 25, 0)
if err != nil {
t.Fatalf("list: %v", err)
}
if total != 2 || len(all) != 2 {
t.Fatalf("want this tenant's 2 bookings, got %d (total %d)", len(all), total)
}
confirmed, total, err := mine.Repo.ListBookings(ctx, mine.Tenant.ID, "confirmed", 25, 0)
if err != nil {
t.Fatalf("list confirmed: %v", err)
}
if total != 1 || len(confirmed) != 1 || confirmed[0].ID != keep.ID {
t.Fatalf("status filter did not isolate the confirmed booking (got %d)", len(confirmed))
}
if len(confirmed[0].Passengers) != 1 {
t.Error("list should attach passengers to each booking")
}
}
func TestBookingByIDAcrossTenantsIsNotFound(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
ctx := context.Background()
flight := theirs.NewFlight(t, testsupport.FlightOpts{})
made, err := theirs.Repo.CreateBooking(ctx, theirs.Tenant.ID, theirs.Agent.ID, booking(flight.ID, 1))
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := mine.Repo.BookingByID(ctx, mine.Tenant.ID, made.ID); err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound, got %v", err)
}
}
func TestBookingReferencesAreUniqueWithinATenant(t *testing.T) {
f := testsupport.Setup(t)
ctx := context.Background()
flight := f.NewFlight(t, testsupport.FlightOpts{Seats: 100})
seen := map[string]bool{}
for i := 0; i < 25; i++ {
got, err := f.Repo.CreateBooking(ctx, f.Tenant.ID, f.Agent.ID, booking(flight.ID, 1))
if err != nil {
t.Fatalf("create %d: %v", i, err)
}
if seen[got.BookingReference] {
t.Fatalf("duplicate booking reference %q", got.BookingReference)
}
seen[got.BookingReference] = true
}
}
func TestDashboardStatsCountOnlyThisTenant(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
ctx := context.Background()
flight := mine.NewFlight(t, testsupport.FlightOpts{Seats: 50, Price: 200})
if _, err := mine.Repo.CreateBooking(ctx, mine.Tenant.ID, mine.Agent.ID, booking(flight.ID, 2)); err != nil {
t.Fatalf("create: %v", err)
}
cancelled, err := mine.Repo.CreateBooking(ctx, mine.Tenant.ID, mine.Agent.ID, booking(flight.ID, 1))
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := mine.Repo.CancelBooking(ctx, mine.Tenant.ID, cancelled.ID); err != nil {
t.Fatalf("cancel: %v", err)
}
otherFlight := theirs.NewFlight(t, testsupport.FlightOpts{Seats: 50, Price: 999})
if _, err := theirs.Repo.CreateBooking(ctx, theirs.Tenant.ID, theirs.Agent.ID, booking(otherFlight.ID, 4)); err != nil {
t.Fatalf("create for other tenant: %v", err)
}
stats, err := mine.Repo.DashboardStats(ctx, mine.Tenant.ID)
if err != nil {
t.Fatalf("stats: %v", err)
}
if stats.TotalBookings != 2 {
t.Errorf("TotalBookings = %d, want 2", stats.TotalBookings)
}
if stats.ConfirmedBookings != 1 {
t.Errorf("ConfirmedBookings = %d, want 1", stats.ConfirmedBookings)
}
if stats.CancelledBookings != 1 {
t.Errorf("CancelledBookings = %d, want 1", stats.CancelledBookings)
}
// Revenue counts confirmed sales only: 200 x 2.
if stats.TotalRevenue != 400 {
t.Errorf("TotalRevenue = %v, want 400", stats.TotalRevenue)
}
if stats.SeatsSold != 2 {
t.Errorf("SeatsSold = %d, want 2", stats.SeatsSold)
}
if stats.FlightsInStock != 1 {
t.Errorf("FlightsInStock = %d, want 1", stats.FlightsInStock)
}
if len(stats.TopRoutes) != 1 || stats.TopRoutes[0].Revenue != 400 {
t.Errorf("TopRoutes did not summarise this tenant's sales: %+v", stats.TopRoutes)
}
}
+149
View File
@@ -0,0 +1,149 @@
package repository
import (
"context"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// flightSelect is the projection shared by every flight read, joining the two
// global reference tables so the API can return nested airline/airport objects.
const flightSelect = `
SELECT f.id, f.flight_number, f.departure_time, f.arrival_time, f.aircraft,
f.cabin_class, f.base_price, f.currency, f.seats_total,
f.seats_available, f.status,
al.id, al.iata_code, al.name, COALESCE(al.logo_url, ''),
o.id, o.iata_code, o.name, o.city, o.country, o.timezone,
d.id, d.iata_code, d.name, d.city, d.country, d.timezone
FROM flights f
JOIN airlines al ON al.id = f.airline_id
JOIN airports o ON o.id = f.origin_airport_id
JOIN airports d ON d.id = f.destination_airport_id`
// scanFlight reads one row of flightSelect.
func scanFlight(row pgx.Row) (models.Flight, error) {
var f models.Flight
err := row.Scan(
&f.ID, &f.FlightNumber, &f.DepartureTime, &f.ArrivalTime, &f.Aircraft,
&f.CabinClass, &f.BasePrice, &f.Currency, &f.SeatsTotal,
&f.SeatsAvailable, &f.Status,
&f.Airline.ID, &f.Airline.IATACode, &f.Airline.Name, &f.Airline.LogoURL,
&f.Origin.ID, &f.Origin.IATACode, &f.Origin.Name, &f.Origin.City,
&f.Origin.Country, &f.Origin.Timezone,
&f.Destination.ID, &f.Destination.IATACode, &f.Destination.Name,
&f.Destination.City, &f.Destination.Country, &f.Destination.Timezone,
)
if err != nil {
return models.Flight{}, err
}
f.DurationMinutes = int(f.ArrivalTime.Sub(f.DepartureTime).Minutes())
return f, nil
}
// searchFilters turns a FlightSearch into WHERE fragments plus their arguments.
// tenantID is always $1, so every generated query is tenant-scoped by
// construction rather than by the caller remembering to add it.
func searchFilters(tenantID uuid.UUID, s models.FlightSearch) ([]string, []any) {
where := []string{"f.tenant_id = $1", "f.status <> 'cancelled'", "f.departure_time > now()"}
args := []any{tenantID}
add := func(clause string, value any) {
args = append(args, value)
where = append(where, fmt.Sprintf(clause, len(args)))
}
// Airports are matched by IATA code through a subquery so the count query
// does not need the joins that flightSelect carries.
if s.Origin != "" {
add("f.origin_airport_id = (SELECT id FROM airports WHERE iata_code = upper($%d))", s.Origin)
}
if s.Destination != "" {
add("f.destination_airport_id = (SELECT id FROM airports WHERE iata_code = upper($%d))", s.Destination)
}
if s.CabinClass != "" {
add("f.cabin_class = $%d", s.CabinClass)
}
if s.Passengers > 0 {
add("f.seats_available >= $%d", s.Passengers)
}
if s.DepartDate != nil {
// Half-open range keeps the composite index on departure_time usable.
args = append(args, *s.DepartDate)
where = append(where, fmt.Sprintf(
"f.departure_time >= $%d AND f.departure_time < ($%d::timestamptz + interval '1 day')",
len(args), len(args)))
}
return where, args
}
// orderClause whitelists sort options; anything unrecognised falls back to
// price so a hostile sortBy value can never reach the SQL text.
func orderClause(sortBy string) string {
switch sortBy {
case "duration":
return "ORDER BY (f.arrival_time - f.departure_time) ASC, f.base_price ASC"
case "departure":
return "ORDER BY f.departure_time ASC, f.base_price ASC"
default:
return "ORDER BY f.base_price ASC, f.departure_time ASC"
}
}
// SearchFlights returns one page of a tenant's inventory plus the total count
// matching the filters.
func (r *Repository) SearchFlights(ctx context.Context, tenantID uuid.UUID, s models.FlightSearch) ([]models.Flight, int, error) {
where, args := searchFilters(tenantID, s)
whereSQL := "WHERE " + strings.Join(where, " AND ")
var total int
countSQL := "SELECT count(*) FROM flights f " + whereSQL
if err := r.pool.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count flights: %w", err)
}
limit := s.Limit
if limit <= 0 || limit > 100 {
limit = 25
}
offset := s.Offset
if offset < 0 {
offset = 0
}
pageArgs := append(append([]any{}, args...), limit, offset)
listSQL := fmt.Sprintf("%s %s %s LIMIT $%d OFFSET $%d",
flightSelect, whereSQL, orderClause(s.SortBy), len(args)+1, len(args)+2)
rows, err := r.pool.Query(ctx, listSQL, pageArgs...)
if err != nil {
return nil, 0, fmt.Errorf("list flights: %w", err)
}
defer rows.Close()
flights := []models.Flight{}
for rows.Next() {
f, err := scanFlight(rows)
if err != nil {
return nil, 0, err
}
flights = append(flights, f)
}
return flights, total, rows.Err()
}
// FlightByID fetches a single flight belonging to the tenant.
func (r *Repository) FlightByID(ctx context.Context, tenantID, flightID uuid.UUID) (models.Flight, error) {
q := flightSelect + ` WHERE f.tenant_id = $1 AND f.id = $2`
f, err := scanFlight(r.pool.QueryRow(ctx, q, tenantID, flightID))
if errors.Is(err, pgx.ErrNoRows) {
return models.Flight{}, ErrNotFound
}
return f, err
}
@@ -0,0 +1,280 @@
package repository_test
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/travelswitch/travelhub/backend/internal/models"
"github.com/travelswitch/travelhub/backend/internal/repository"
"github.com/travelswitch/travelhub/backend/internal/testsupport"
)
func TestSearchFlightsFiltersByRoute(t *testing.T) {
f := testsupport.Setup(t)
ctx := context.Background()
want := f.NewFlight(t, testsupport.FlightOpts{Origin: "JFK", Destination: "LHR"})
f.NewFlight(t, testsupport.FlightOpts{Origin: "LAX", Destination: "DXB"})
got, total, err := f.Repo.SearchFlights(ctx, f.Tenant.ID, models.FlightSearch{
Origin: "JFK", Destination: "LHR",
})
if err != nil {
t.Fatalf("search: %v", err)
}
if total != 1 || len(got) != 1 {
t.Fatalf("want 1 flight, got %d (total %d)", len(got), total)
}
if got[0].ID != want.ID {
t.Errorf("wrong flight: got %s want %s", got[0].ID, want.ID)
}
}
func TestSearchFlightsLowercaseIATAStillMatches(t *testing.T) {
f := testsupport.Setup(t)
f.NewFlight(t, testsupport.FlightOpts{Origin: "JFK", Destination: "LHR"})
// The repository upper-cases codes in SQL, so a sloppy client still works.
got, _, err := f.Repo.SearchFlights(context.Background(), f.Tenant.ID, models.FlightSearch{
Origin: "jfk", Destination: "lhr",
})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(got) != 1 {
t.Fatalf("want 1 flight for lowercase codes, got %d", len(got))
}
}
// The headline guarantee: one agency's inventory is invisible to another.
func TestSearchFlightsIsolatesTenants(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
ctx := context.Background()
mine.NewFlight(t, testsupport.FlightOpts{Number: "MINE1"})
theirs.NewFlight(t, testsupport.FlightOpts{Number: "THEIRS1"})
theirs.NewFlight(t, testsupport.FlightOpts{Number: "THEIRS2"})
got, total, err := mine.Repo.SearchFlights(ctx, mine.Tenant.ID, models.FlightSearch{})
if err != nil {
t.Fatalf("search: %v", err)
}
if total != 1 || len(got) != 1 {
t.Fatalf("tenant should see exactly its own 1 flight, saw %d (total %d)", len(got), total)
}
if got[0].FlightNumber != "MINE1" {
t.Errorf("leaked another tenant's flight: %s", got[0].FlightNumber)
}
}
func TestFlightByIDAcrossTenantsIsNotFound(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
other := theirs.NewFlight(t, testsupport.FlightOpts{})
// Even holding a valid id, the wrong tenant must get ErrNotFound -- not a
// permission error, which would confirm the record exists.
_, err := mine.Repo.FlightByID(context.Background(), mine.Tenant.ID, other.ID)
if err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound for cross-tenant read, got %v", err)
}
}
func TestFlightByIDUnknownIsNotFound(t *testing.T) {
f := testsupport.Setup(t)
_, err := f.Repo.FlightByID(context.Background(), f.Tenant.ID, uuid.New())
if err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound, got %v", err)
}
}
func TestSearchFlightsHidesDepartedAndCancelled(t *testing.T) {
f := testsupport.Setup(t)
f.NewFlight(t, testsupport.FlightOpts{Number: "FUTURE", DepartsIn: 24 * time.Hour})
f.NewFlight(t, testsupport.FlightOpts{Number: "PAST", DepartsIn: -24 * time.Hour})
f.NewFlight(t, testsupport.FlightOpts{Number: "GONE", Status: "cancelled"})
got, _, err := f.Repo.SearchFlights(context.Background(), f.Tenant.ID, models.FlightSearch{})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(got) != 1 || got[0].FlightNumber != "FUTURE" {
names := make([]string, len(got))
for i, fl := range got {
names[i] = fl.FlightNumber
}
t.Fatalf("want only FUTURE, got %v", names)
}
}
func TestSearchFlightsRespectsPartySize(t *testing.T) {
f := testsupport.Setup(t)
two := 2
five := 5
f.NewFlight(t, testsupport.FlightOpts{Number: "TIGHT", Seats: 10, Available: &two})
f.NewFlight(t, testsupport.FlightOpts{Number: "ROOMY", Seats: 10, Available: &five})
got, _, err := f.Repo.SearchFlights(context.Background(), f.Tenant.ID, models.FlightSearch{
Passengers: 4,
})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(got) != 1 || got[0].FlightNumber != "ROOMY" {
t.Fatalf("a party of 4 should only see ROOMY, got %d results", len(got))
}
}
func TestSearchFlightsFiltersByCabin(t *testing.T) {
f := testsupport.Setup(t)
f.NewFlight(t, testsupport.FlightOpts{Number: "ECON", Cabin: "economy"})
f.NewFlight(t, testsupport.FlightOpts{Number: "BIZ", Cabin: "business"})
got, _, err := f.Repo.SearchFlights(context.Background(), f.Tenant.ID, models.FlightSearch{
CabinClass: "business",
})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(got) != 1 || got[0].FlightNumber != "BIZ" {
t.Fatalf("want only the business flight, got %d results", len(got))
}
}
func TestSearchFlightsFiltersByDepartureDay(t *testing.T) {
f := testsupport.Setup(t)
// Land both flights at a fixed hour so the test cannot straddle midnight.
tomorrow := time.Now().AddDate(0, 0, 1)
target := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 9, 0, 0, 0, time.Local)
f.NewFlight(t, testsupport.FlightOpts{Number: "ONDAY", DepartsIn: time.Until(target)})
f.NewFlight(t, testsupport.FlightOpts{Number: "OFFDAY", DepartsIn: time.Until(target.AddDate(0, 0, 3))})
day := time.Date(target.Year(), target.Month(), target.Day(), 0, 0, 0, 0, time.Local)
got, _, err := f.Repo.SearchFlights(context.Background(), f.Tenant.ID, models.FlightSearch{
DepartDate: &day,
})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(got) != 1 || got[0].FlightNumber != "ONDAY" {
t.Fatalf("want only ONDAY for that calendar day, got %d results", len(got))
}
}
func TestSearchFlightsSorting(t *testing.T) {
f := testsupport.Setup(t)
ctx := context.Background()
f.NewFlight(t, testsupport.FlightOpts{
Number: "CHEAPSLOW", Price: 100, Duration: 12 * time.Hour, DepartsIn: 72 * time.Hour,
})
f.NewFlight(t, testsupport.FlightOpts{
Number: "DEARFAST", Price: 900, Duration: 2 * time.Hour, DepartsIn: 24 * time.Hour,
})
cases := []struct {
sortBy string
want string
}{
{"price", "CHEAPSLOW"},
{"duration", "DEARFAST"},
{"departure", "DEARFAST"},
{"nonsense-falls-back-to-price", "CHEAPSLOW"},
}
for _, tc := range cases {
t.Run(tc.sortBy, func(t *testing.T) {
got, _, err := f.Repo.SearchFlights(ctx, f.Tenant.ID, models.FlightSearch{SortBy: tc.sortBy})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(got) == 0 {
t.Fatal("no results")
}
if got[0].FlightNumber != tc.want {
t.Errorf("sort %q: first result %s, want %s", tc.sortBy, got[0].FlightNumber, tc.want)
}
})
}
}
func TestSearchFlightsPaginates(t *testing.T) {
f := testsupport.Setup(t)
ctx := context.Background()
for i := 0; i < 5; i++ {
f.NewFlight(t, testsupport.FlightOpts{Price: float64(100 * (i + 1))})
}
first, total, err := f.Repo.SearchFlights(ctx, f.Tenant.ID, models.FlightSearch{Limit: 2, Offset: 0})
if err != nil {
t.Fatalf("search: %v", err)
}
if total != 5 {
t.Errorf("total = %d, want 5", total)
}
if len(first) != 2 {
t.Fatalf("page size = %d, want 2", len(first))
}
second, _, err := f.Repo.SearchFlights(ctx, f.Tenant.ID, models.FlightSearch{Limit: 2, Offset: 2})
if err != nil {
t.Fatalf("search page 2: %v", err)
}
if len(second) != 2 {
t.Fatalf("second page size = %d, want 2", len(second))
}
if first[0].ID == second[0].ID {
t.Error("second page repeated the first page's first row")
}
}
func TestSearchFlightsComputesDuration(t *testing.T) {
f := testsupport.Setup(t)
f.NewFlight(t, testsupport.FlightOpts{Duration: 7*time.Hour + 25*time.Minute})
got, _, err := f.Repo.SearchFlights(context.Background(), f.Tenant.ID, models.FlightSearch{})
if err != nil {
t.Fatalf("search: %v", err)
}
if got[0].DurationMinutes != 445 {
t.Errorf("DurationMinutes = %d, want 445", got[0].DurationMinutes)
}
}
func TestListAirportsCoversOnlyThisTenantsNetwork(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
mine.NewFlight(t, testsupport.FlightOpts{Origin: "JFK", Destination: "LHR"})
theirs.NewFlight(t, testsupport.FlightOpts{Origin: "LAX", Destination: "DXB"})
airports, err := mine.Repo.ListAirports(context.Background(), mine.Tenant.ID)
if err != nil {
t.Fatalf("list airports: %v", err)
}
seen := map[string]bool{}
for _, a := range airports {
seen[a.IATACode] = true
}
if !seen["JFK"] || !seen["LHR"] {
t.Errorf("want JFK and LHR in the network, got %v", seen)
}
if seen["LAX"] || seen["DXB"] {
t.Errorf("leaked the other tenant's airports: %v", seen)
}
}
@@ -0,0 +1,72 @@
package repository
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// TenantBySlug resolves the tenant a caller is signing in to. Suspended
// tenants are treated as absent so their users cannot authenticate.
func (r *Repository) TenantBySlug(ctx context.Context, slug string) (models.Tenant, error) {
const q = `
SELECT id, slug, name, status, created_at
FROM tenants
WHERE slug = $1 AND status = 'active'`
var t models.Tenant
err := r.pool.QueryRow(ctx, q, slug).
Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return models.Tenant{}, ErrNotFound
}
return t, err
}
// ListTenants powers the tenant picker on the sign-in screen. It is the only
// unauthenticated read in the service and deliberately exposes nothing beyond
// the slug and display name.
func (r *Repository) ListTenants(ctx context.Context) ([]models.Tenant, error) {
const q = `
SELECT id, slug, name, status, created_at
FROM tenants
WHERE status = 'active'
ORDER BY name`
rows, err := r.pool.Query(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
tenants := []models.Tenant{}
for rows.Next() {
var t models.Tenant
if err := rows.Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt); err != nil {
return nil, err
}
tenants = append(tenants, t)
}
return tenants, rows.Err()
}
// UserByEmail looks up a login within one tenant. The tenant filter is what
// stops credentials from one agency authenticating against another.
func (r *Repository) UserByEmail(ctx context.Context, tenantID uuid.UUID, email string) (models.User, error) {
const q = `
SELECT id, tenant_id, email, password_hash, full_name, role, created_at
FROM users
WHERE tenant_id = $1 AND lower(email) = lower($2)`
var u models.User
err := r.pool.QueryRow(ctx, q, tenantID, email).Scan(
&u.ID, &u.TenantID, &u.Email, &u.PasswordHash, &u.FullName, &u.Role, &u.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return models.User{}, ErrNotFound
}
return u, err
}
@@ -0,0 +1,117 @@
package repository_test
import (
"context"
"strings"
"testing"
"github.com/travelswitch/travelhub/backend/internal/repository"
"github.com/travelswitch/travelhub/backend/internal/testsupport"
)
func TestTenantBySlug(t *testing.T) {
f := testsupport.Setup(t)
got, err := f.Repo.TenantBySlug(context.Background(), f.Tenant.Slug)
if err != nil {
t.Fatalf("lookup: %v", err)
}
if got.ID != f.Tenant.ID {
t.Errorf("got tenant %s, want %s", got.ID, f.Tenant.ID)
}
}
func TestTenantBySlugUnknownIsNotFound(t *testing.T) {
f := testsupport.Setup(t)
if _, err := f.Repo.TenantBySlug(context.Background(), "no-such-agency"); err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound, got %v", err)
}
}
// A suspended tenant must be indistinguishable from a missing one, so its
// users cannot sign in.
func TestTenantBySlugSuspendedIsNotFound(t *testing.T) {
f := testsupport.Setup(t)
f.Suspend(t)
if _, err := f.Repo.TenantBySlug(context.Background(), f.Tenant.Slug); err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound for a suspended tenant, got %v", err)
}
}
func TestUserByEmailIsCaseInsensitive(t *testing.T) {
f := testsupport.Setup(t)
got, err := f.Repo.UserByEmail(context.Background(), f.Tenant.ID, strings.ToUpper(f.Agent.Email))
if err != nil {
t.Fatalf("lookup: %v", err)
}
if got.ID != f.Agent.ID {
t.Errorf("got user %s, want %s", got.ID, f.Agent.ID)
}
}
// The tenant filter on the user lookup is what stops one agency's credentials
// from authenticating at another.
func TestUserByEmailIsScopedToTenant(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
_, err := mine.Repo.UserByEmail(context.Background(), mine.Tenant.ID, theirs.Agent.Email)
if err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound looking up another tenant's user, got %v", err)
}
}
// The same address may hold an account at more than one agency; each must
// resolve to its own row.
func TestSameEmailCanExistInTwoTenants(t *testing.T) {
a := testsupport.Setup(t)
b := testsupport.Setup(t)
const shared = "traveller@example.com"
inA := a.NewUser(t, shared, "agent")
inB := b.NewUser(t, shared, "admin")
gotA, err := a.Repo.UserByEmail(context.Background(), a.Tenant.ID, shared)
if err != nil {
t.Fatalf("lookup in A: %v", err)
}
gotB, err := b.Repo.UserByEmail(context.Background(), b.Tenant.ID, shared)
if err != nil {
t.Fatalf("lookup in B: %v", err)
}
if gotA.ID != inA.ID || gotB.ID != inB.ID {
t.Error("shared email resolved to the wrong tenant's user")
}
if gotA.ID == gotB.ID {
t.Error("the two tenants collapsed onto one user row")
}
if gotA.Role != "agent" || gotB.Role != "admin" {
t.Errorf("roles crossed over: A=%s B=%s", gotA.Role, gotB.Role)
}
}
func TestListTenantsExcludesSuspended(t *testing.T) {
live := testsupport.Setup(t)
dead := testsupport.Setup(t)
dead.Suspend(t)
tenants, err := live.Repo.ListTenants(context.Background())
if err != nil {
t.Fatalf("list: %v", err)
}
seen := map[string]bool{}
for _, tn := range tenants {
seen[tn.Slug] = true
}
if !seen[live.Tenant.Slug] {
t.Error("active tenant missing from the sign-in picker")
}
if seen[dead.Tenant.Slug] {
t.Error("suspended tenant offered in the sign-in picker")
}
}
@@ -0,0 +1,135 @@
package repository
import (
"context"
"github.com/google/uuid"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// ListAirports returns the airports this tenant actually flies to or from,
// which is what the search form's dropdowns should offer. Airports themselves
// are global, but the filter keeps each agency's UI limited to its network.
func (r *Repository) ListAirports(ctx context.Context, tenantID uuid.UUID) ([]models.Airport, error) {
const q = `
SELECT DISTINCT a.id, a.iata_code, a.name, a.city, a.country, a.timezone
FROM airports a
WHERE EXISTS (
SELECT 1 FROM flights f
WHERE f.tenant_id = $1
AND (f.origin_airport_id = a.id OR f.destination_airport_id = a.id)
)
ORDER BY a.city, a.iata_code`
rows, err := r.pool.Query(ctx, q, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
airports := []models.Airport{}
for rows.Next() {
var a models.Airport
if err := rows.Scan(&a.ID, &a.IATACode, &a.Name, &a.City, &a.Country, &a.Timezone); err != nil {
return nil, err
}
airports = append(airports, a)
}
return airports, rows.Err()
}
// ListAirlines returns the carriers present in this tenant's inventory.
func (r *Repository) ListAirlines(ctx context.Context, tenantID uuid.UUID) ([]models.Airline, error) {
const q = `
SELECT DISTINCT al.id, al.iata_code, al.name, COALESCE(al.logo_url, '')
FROM airlines al
JOIN flights f ON f.airline_id = al.id AND f.tenant_id = $1
ORDER BY al.name`
rows, err := r.pool.Query(ctx, q, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
airlines := []models.Airline{}
for rows.Next() {
var a models.Airline
if err := rows.Scan(&a.ID, &a.IATACode, &a.Name, &a.LogoURL); err != nil {
return nil, err
}
airlines = append(airlines, a)
}
return airlines, rows.Err()
}
// DashboardStats aggregates one tenant's trading position.
func (r *Repository) DashboardStats(ctx context.Context, tenantID uuid.UUID) (models.DashboardStats, error) {
var s models.DashboardStats
s.Currency = "USD"
const totalsSQL = `
SELECT COUNT(*),
COUNT(*) FILTER (WHERE status = 'confirmed'),
COUNT(*) FILTER (WHERE status = 'cancelled'),
COALESCE(SUM(total_amount) FILTER (WHERE status = 'confirmed'), 0),
COALESCE(SUM(passenger_count) FILTER (WHERE status = 'confirmed'), 0)
FROM bookings
WHERE tenant_id = $1`
if err := r.pool.QueryRow(ctx, totalsSQL, tenantID).Scan(
&s.TotalBookings, &s.ConfirmedBookings, &s.CancelledBookings,
&s.TotalRevenue, &s.SeatsSold,
); err != nil {
return s, err
}
const stockSQL = `
SELECT COUNT(*) FROM flights
WHERE tenant_id = $1 AND departure_time > now() AND status <> 'cancelled'`
if err := r.pool.QueryRow(ctx, stockSQL, tenantID).Scan(&s.FlightsInStock); err != nil {
return s, err
}
const routesSQL = `
SELECT o.iata_code || ' to ' || d.iata_code AS route,
COUNT(*) AS bookings,
COALESCE(SUM(b.total_amount), 0) AS revenue
FROM bookings b
JOIN flights f ON f.id = b.flight_id
JOIN airports o ON o.id = f.origin_airport_id
JOIN airports d ON d.id = f.destination_airport_id
WHERE b.tenant_id = $1 AND b.status = 'confirmed'
GROUP BY route
ORDER BY revenue DESC
LIMIT 5`
rows, err := r.pool.Query(ctx, routesSQL, tenantID)
if err != nil {
return s, err
}
defer rows.Close()
for rows.Next() {
var entry struct {
Route string `json:"route"`
Bookings int `json:"bookings"`
Revenue float64 `json:"revenue"`
}
if err := rows.Scan(&entry.Route, &entry.Bookings, &entry.Revenue); err != nil {
return s, err
}
s.TopRoutes = append(s.TopRoutes, entry)
}
if err := rows.Err(); err != nil {
return s, err
}
recent, _, err := r.ListBookings(ctx, tenantID, "", 5, 0)
if err != nil {
return s, err
}
s.RecentBookings = recent
return s, nil
}
+39
View File
@@ -0,0 +1,39 @@
// Package repository holds every SQL statement in the service.
//
// Tenant isolation convention: any query touching a tenant-owned table
// (users, flights, bookings, passengers) takes tenantID as its FIRST argument
// and filters on it in the WHERE clause. Callers pass the id from the
// authenticated identity, never from client input.
package repository
import (
"errors"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrNotFound is returned when a lookup matches no row within the tenant.
// Handlers map it to 404, which also means a caller probing for another
// tenant's record sees a plain "not found" rather than a permission error.
var ErrNotFound = errors.New("not found")
// ErrNoSeats is returned when a booking asks for more seats than remain.
var ErrNoSeats = errors.New("not enough seats available")
// Repository is the data access layer over a pgx pool.
type Repository struct {
pool *pgxpool.Pool
}
// New builds a Repository around an open pool.
func New(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
}
// isUniqueViolation reports whether err is PostgreSQL's 23505, which lets
// callers retry a generated value instead of failing the request.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
+329
View File
@@ -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)))
}