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,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})
|
||||
}
|
||||
Reference in New Issue
Block a user