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