Initial commit: TravelHub multi-tenant flight booking platform

Angular + Go + PostgreSQL sample with strict per-tenant isolation.

- Backend: Go API (basic auth, tenant-scoped repository layer, pgx pool)
- Frontend: Angular 22 SPA (search, checkout, bookings, dashboard)
- Database: schema, seed data and demo bookings migrations
- Tests: 311 across Go (integration), Angular (unit) and Playwright (e2e),
  plus a generated evidence report with PDF export
- Security: whitebox VAPT report under security/

Secrets (.env, tokens, DB credentials) are git-ignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nilesh.p
2026-09-13 11:38:01 +05:30
co-authored by Claude Opus 4.8
commit 4d3eb18a6c
129 changed files with 25424 additions and 0 deletions
+162
View File
@@ -0,0 +1,162 @@
package handlers
import (
"encoding/json"
"net/http"
"net/mail"
"strings"
"github.com/google/uuid"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// ListBookings handles GET /api/bookings, optionally filtered by status.
func (h *Handler) ListBookings(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
status := strings.TrimSpace(r.URL.Query().Get("status"))
if status != "" && status != "confirmed" && status != "cancelled" {
writeError(w, http.StatusBadRequest, "status must be confirmed or cancelled")
return
}
limit := intParam(r, "limit", 25, 1, 100)
offset := intParam(r, "offset", 0, 0, 100000)
bookings, total, err := h.Repo.ListBookings(r.Context(), id.Tenant.ID, status, limit, offset)
if err != nil {
h.fail(w, err, "list bookings")
return
}
writeJSON(w, http.StatusOK, page[models.Booking]{
Items: bookings, Total: total, Limit: limit, Offset: offset,
})
}
// GetBooking handles GET /api/bookings/{id}.
func (h *Handler) GetBooking(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
bookingID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid booking id")
return
}
booking, err := h.Repo.BookingByID(r.Context(), id.Tenant.ID, bookingID)
if err != nil {
h.fail(w, err, "get booking")
return
}
writeJSON(w, http.StatusOK, booking)
}
// CreateBooking handles POST /api/bookings.
func (h *Handler) CreateBooking(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
var req models.CreateBookingRequest
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return
}
if msg := validateBooking(&req); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
booking, err := h.Repo.CreateBooking(r.Context(), id.Tenant.ID, id.User.ID, req)
if err != nil {
h.fail(w, err, "create booking")
return
}
writeJSON(w, http.StatusCreated, booking)
}
// CancelBooking handles POST /api/bookings/{id}/cancel and returns the seats
// to inventory.
func (h *Handler) CancelBooking(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
bookingID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid booking id")
return
}
booking, err := h.Repo.CancelBooking(r.Context(), id.Tenant.ID, bookingID)
if err != nil {
h.fail(w, err, "cancel booking")
return
}
writeJSON(w, http.StatusOK, booking)
}
// validateBooking returns an empty string when the payload is usable, or a
// message naming the first problem. Normalisation happens here too, so the
// repository only ever sees trimmed values.
func validateBooking(req *models.CreateBookingRequest) string {
if req.FlightID == uuid.Nil {
return "flightId is required"
}
req.ContactEmail = strings.TrimSpace(req.ContactEmail)
if req.ContactEmail == "" {
return "contactEmail is required"
}
if _, err := mail.ParseAddress(req.ContactEmail); err != nil {
return "contactEmail is not a valid email address"
}
req.ContactPhone = strings.TrimSpace(req.ContactPhone)
if len(req.Passengers) == 0 {
return "at least one passenger is required"
}
if len(req.Passengers) > 9 {
return "a single booking may carry at most 9 passengers"
}
for i := range req.Passengers {
p := &req.Passengers[i]
p.FirstName = strings.TrimSpace(p.FirstName)
p.LastName = strings.TrimSpace(p.LastName)
p.PassportNumber = strings.TrimSpace(p.PassportNumber)
p.DateOfBirth = strings.TrimSpace(p.DateOfBirth)
if p.FirstName == "" || p.LastName == "" {
return "every passenger needs a first and last name"
}
}
return ""
}
// Dashboard handles GET /api/dashboard.
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
stats, err := h.Repo.DashboardStats(r.Context(), id.Tenant.ID)
if err != nil {
h.fail(w, err, "dashboard stats")
return
}
writeJSON(w, http.StatusOK, stats)
}
+118
View File
@@ -0,0 +1,118 @@
package handlers
import (
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// SearchFlights handles GET /api/flights.
//
// Query parameters: origin, destination (IATA codes), date (YYYY-MM-DD),
// cabin, passengers, sort (price|duration|departure), limit, offset.
func (h *Handler) SearchFlights(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
q := r.URL.Query()
search := models.FlightSearch{
Origin: strings.ToUpper(strings.TrimSpace(q.Get("origin"))),
Destination: strings.ToUpper(strings.TrimSpace(q.Get("destination"))),
CabinClass: strings.TrimSpace(q.Get("cabin")),
Passengers: intParam(r, "passengers", 1, 1, 9),
SortBy: q.Get("sort"),
Limit: intParam(r, "limit", 25, 1, 100),
Offset: intParam(r, "offset", 0, 0, 100000),
}
if raw := strings.TrimSpace(q.Get("date")); raw != "" {
// Parsed in the server's local zone, which is what an agent selling
// from a desk in that zone means by "flights on the 12th".
d, err := time.ParseInLocation("2006-01-02", raw, time.Local)
if err != nil {
writeError(w, http.StatusBadRequest, "date must be in YYYY-MM-DD format")
return
}
search.DepartDate = &d
}
if search.CabinClass != "" && !validCabin(search.CabinClass) {
writeError(w, http.StatusBadRequest,
"cabin must be one of economy, premium_economy, business, first")
return
}
flights, total, err := h.Repo.SearchFlights(r.Context(), id.Tenant.ID, search)
if err != nil {
h.fail(w, err, "search flights")
return
}
writeJSON(w, http.StatusOK, page[models.Flight]{
Items: flights, Total: total, Limit: search.Limit, Offset: search.Offset,
})
}
// GetFlight handles GET /api/flights/{id}.
func (h *Handler) GetFlight(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
flightID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid flight id")
return
}
flight, err := h.Repo.FlightByID(r.Context(), id.Tenant.ID, flightID)
if err != nil {
h.fail(w, err, "get flight")
return
}
writeJSON(w, http.StatusOK, flight)
}
// ListAirports handles GET /api/airports.
func (h *Handler) ListAirports(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
airports, err := h.Repo.ListAirports(r.Context(), id.Tenant.ID)
if err != nil {
h.fail(w, err, "list airports")
return
}
writeJSON(w, http.StatusOK, airports)
}
// ListAirlines handles GET /api/airlines.
func (h *Handler) ListAirlines(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
airlines, err := h.Repo.ListAirlines(r.Context(), id.Tenant.ID)
if err != nil {
h.fail(w, err, "list airlines")
return
}
writeJSON(w, http.StatusOK, airlines)
}
func validCabin(c string) bool {
switch c {
case "economy", "premium_economy", "business", "first":
return true
}
return false
}
+96
View File
@@ -0,0 +1,96 @@
// Package handlers implements the TravelHub JSON API.
//
// Handlers never read a tenant id from the URL, query string, or body. They
// take it from the authenticated identity, which the Basic auth middleware
// resolved from the credentials. That keeps "which tenant am I acting as"
// impossible for a client to influence.
package handlers
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/travelswitch/travelhub/backend/internal/auth"
"github.com/travelswitch/travelhub/backend/internal/repository"
)
// Handler carries the dependencies shared by every endpoint.
type Handler struct {
Repo *repository.Repository
Logger *slog.Logger
}
// New builds a Handler.
func New(repo *repository.Repository, logger *slog.Logger) *Handler {
return &Handler{Repo: repo, Logger: logger}
}
// page is the envelope for every list endpoint.
type page[T any] struct {
Items []T `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if body != nil {
json.NewEncoder(w).Encode(body)
}
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
// fail maps a repository error onto a status code, logging only the ones that
// represent a server fault.
func (h *Handler) fail(w http.ResponseWriter, err error, context string) {
switch {
case errors.Is(err, repository.ErrNotFound):
writeError(w, http.StatusNotFound, "not found")
case errors.Is(err, repository.ErrNoSeats):
writeError(w, http.StatusConflict, "not enough seats available on this flight")
default:
h.Logger.Error(context, "error", err)
writeError(w, http.StatusInternalServerError, "internal server error")
}
}
// identity pulls the authenticated caller out of the request, replying 401 if
// the route was somehow mounted without the auth middleware.
func (h *Handler) identity(w http.ResponseWriter, r *http.Request) (auth.Identity, bool) {
id, err := auth.FromContext(r.Context())
if err != nil {
writeError(w, http.StatusUnauthorized, "authentication required")
return auth.Identity{}, false
}
return id, true
}
// intParam reads a bounded integer query parameter.
func intParam(r *http.Request, name string, fallback, min, max int) int {
raw := r.URL.Query().Get(name)
if raw == "" {
return fallback
}
v, err := strconv.Atoi(raw)
if err != nil || v < min {
return fallback
}
if v > max {
return max
}
return v
}
// Health reports liveness. It is the only endpoint that needs neither
// credentials nor a tenant.
func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+511
View File
@@ -0,0 +1,511 @@
package handlers_test
import (
"bytes"
"encoding/base64"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/travelswitch/travelhub/backend/internal/handlers"
"github.com/travelswitch/travelhub/backend/internal/httpapi"
"github.com/travelswitch/travelhub/backend/internal/middleware"
"github.com/travelswitch/travelhub/backend/internal/models"
"github.com/travelswitch/travelhub/backend/internal/testsupport"
)
// api spins the real router over a per-test tenant, so these exercise the same
// routing, middleware order and auth rules the server runs.
type api struct {
*testsupport.Fixture
handler http.Handler
}
func newAPI(t *testing.T) *api {
t.Helper()
f := testsupport.Setup(t)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
h := handlers.New(f.Repo, logger)
return &api{
Fixture: f,
handler: httpapi.New(h, f.Repo, logger, []string{"http://localhost:4200"}),
}
}
// do issues an authenticated request as the fixture's agent.
func (a *api) do(t *testing.T, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
return a.as(t, a.Agent.Email, testsupport.Password, a.Tenant.Slug, method, path, body)
}
// anon issues a request with no credentials.
func (a *api) anon(t *testing.T, method, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, nil)
rec := httptest.NewRecorder()
a.handler.ServeHTTP(rec, req)
return rec
}
func (a *api) as(t *testing.T, email, password, tenant, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
reader = bytes.NewReader(raw)
}
req := httptest.NewRequest(method, path, reader)
req.Header.Set("Authorization", "Basic "+
base64.StdEncoding.EncodeToString([]byte(email+":"+password)))
req.Header.Set(middleware.TenantHeader, tenant)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
a.handler.ServeHTTP(rec, req)
return rec
}
func decode[T any](t *testing.T, rec *httptest.ResponseRecorder) T {
t.Helper()
var out T
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode response %s: %v", rec.Body, err)
}
return out
}
func requireStatus(t *testing.T, rec *httptest.ResponseRecorder, want int) {
t.Helper()
if rec.Code != want {
t.Fatalf("status = %d, want %d (body: %s)", rec.Code, want, rec.Body)
}
}
// --- public endpoints ------------------------------------------------------
func TestHealthNeedsNoCredentials(t *testing.T) {
a := newAPI(t)
rec := a.anon(t, http.MethodGet, "/api/health")
requireStatus(t, rec, http.StatusOK)
got := decode[map[string]string](t, rec)
if got["status"] != "ok" {
t.Errorf("body = %v, want status ok", got)
}
}
func TestTenantListIsPublicButThin(t *testing.T) {
a := newAPI(t)
rec := a.anon(t, http.MethodGet, "/api/tenants")
requireStatus(t, rec, http.StatusOK)
// The picker needs a slug and a name; it must not leak ids or timestamps.
got := decode[[]map[string]any](t, rec)
found := false
for _, tn := range got {
if tn["slug"] == a.Tenant.Slug {
found = true
if _, leaked := tn["id"]; leaked {
t.Error("tenant list leaked internal ids")
}
if len(tn) != 2 {
t.Errorf("tenant entry has %d fields, want just slug and name: %v", len(tn), tn)
}
}
}
if !found {
t.Error("the fixture's tenant is missing from the public list")
}
}
func TestProtectedRoutesRejectAnonymous(t *testing.T) {
a := newAPI(t)
for _, path := range []string{
"/api/session", "/api/flights", "/api/bookings", "/api/dashboard",
"/api/airports", "/api/airlines",
} {
t.Run(path, func(t *testing.T) {
// No credentials and no tenant: the tenant check fires first.
rec := a.anon(t, http.MethodGet, path)
if rec.Code != http.StatusBadRequest && rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 400 or 401", rec.Code)
}
})
}
}
// --- session ---------------------------------------------------------------
func TestSessionDescribesTheCaller(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/session", nil)
requireStatus(t, rec, http.StatusOK)
got := decode[struct {
User models.User `json:"user"`
Tenant models.Tenant `json:"tenant"`
}](t, rec)
if got.User.Email != a.Agent.Email {
t.Errorf("user = %s, want %s", got.User.Email, a.Agent.Email)
}
if got.Tenant.Slug != a.Tenant.Slug {
t.Errorf("tenant = %s, want %s", got.Tenant.Slug, a.Tenant.Slug)
}
}
func TestSessionNeverReturnsThePasswordHash(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/session", nil)
requireStatus(t, rec, http.StatusOK)
if bytes.Contains(rec.Body.Bytes(), []byte("password")) {
t.Errorf("session response mentions a password field: %s", rec.Body)
}
}
// --- flights ---------------------------------------------------------------
func TestSearchFlightsReturnsAPage(t *testing.T) {
a := newAPI(t)
a.NewFlight(t, testsupport.FlightOpts{Origin: "JFK", Destination: "LHR"})
rec := a.do(t, http.MethodGet, "/api/flights?origin=JFK&destination=LHR", nil)
requireStatus(t, rec, http.StatusOK)
got := decode[struct {
Items []models.Flight `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}](t, rec)
if got.Total != 1 || len(got.Items) != 1 {
t.Fatalf("want one flight, got %d (total %d)", len(got.Items), got.Total)
}
if got.Limit != 25 {
t.Errorf("default limit = %d, want 25", got.Limit)
}
if got.Items[0].Airline.IATACode == "" || got.Items[0].Origin.City == "" {
t.Error("flight should arrive with its airline and airports expanded")
}
}
func TestSearchFlightsValidatesInput(t *testing.T) {
a := newAPI(t)
cases := []struct {
name string
path string
want int
}{
{"bad date", "/api/flights?date=12-09-2026", http.StatusBadRequest},
{"bad cabin", "/api/flights?cabin=luxury", http.StatusBadRequest},
{"good cabin", "/api/flights?cabin=business", http.StatusOK},
{"absurd limit is clamped", "/api/flights?limit=99999", http.StatusOK},
{"negative offset falls back", "/api/flights?offset=-5", http.StatusOK},
{"non-numeric passengers falls back", "/api/flights?passengers=lots", http.StatusOK},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := a.do(t, http.MethodGet, tc.path, nil)
if rec.Code != tc.want {
t.Errorf("status = %d, want %d (body: %s)", rec.Code, tc.want, rec.Body)
}
})
}
}
func TestGetFlightRejectsAMalformedID(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/flights/not-a-uuid", nil)
requireStatus(t, rec, http.StatusBadRequest)
}
func TestGetFlightAcrossTenantsIsFourOhFour(t *testing.T) {
a := newAPI(t)
other := testsupport.Setup(t)
theirFlight := other.NewFlight(t, testsupport.FlightOpts{})
rec := a.do(t, http.MethodGet, "/api/flights/"+theirFlight.ID.String(), nil)
requireStatus(t, rec, http.StatusNotFound)
}
// --- bookings --------------------------------------------------------------
func newBooking(flightID uuid.UUID, n int) models.CreateBookingRequest {
req := models.CreateBookingRequest{
FlightID: flightID,
ContactEmail: "ops@example.com",
ContactPhone: "+1-555-0100",
}
for i := 0; i < n; i++ {
req.Passengers = append(req.Passengers, models.PassengerInput{
FirstName: "Test", LastName: "Traveller",
})
}
return req
}
func TestCreateBookingReturnsCreated(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20, Price: 300})
rec := a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 2))
requireStatus(t, rec, http.StatusCreated)
got := decode[models.Booking](t, rec)
if got.TotalAmount != 600 {
t.Errorf("TotalAmount = %v, want 600", got.TotalAmount)
}
if len(got.Passengers) != 2 {
t.Errorf("got %d passengers, want 2", len(got.Passengers))
}
if left := a.SeatsAvailable(t, flight.ID); left != 18 {
t.Errorf("seats = %d, want 18", left)
}
}
func TestCreateBookingValidation(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20})
noName := newBooking(flight.ID, 1)
noName.Passengers[0].FirstName = " "
tooMany := newBooking(flight.ID, 10)
cases := []struct {
name string
body models.CreateBookingRequest
}{
{"missing flight id", models.CreateBookingRequest{
ContactEmail: "a@b.co",
Passengers: []models.PassengerInput{{FirstName: "A", LastName: "B"}},
}},
{"missing contact email", models.CreateBookingRequest{
FlightID: flight.ID,
Passengers: []models.PassengerInput{{FirstName: "A", LastName: "B"}},
}},
{"malformed contact email", models.CreateBookingRequest{
FlightID: flight.ID,
ContactEmail: "definitely-not-an-email",
Passengers: []models.PassengerInput{{FirstName: "A", LastName: "B"}},
}},
{"no passengers", models.CreateBookingRequest{
FlightID: flight.ID, ContactEmail: "a@b.co",
}},
{"blank passenger name", noName},
{"more than nine passengers", tooMany},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := a.do(t, http.MethodPost, "/api/bookings", tc.body)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 (body: %s)", rec.Code, rec.Body)
}
})
}
if left := a.SeatsAvailable(t, flight.ID); left != 20 {
t.Errorf("rejected bookings consumed seats: %d left, want 20", left)
}
}
func TestCreateBookingRejectsUnknownFields(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20})
// A client trying to name its own tenant must not be quietly ignored.
body := map[string]any{
"flightId": flight.ID,
"contactEmail": "ops@example.com",
"tenantId": uuid.New(),
"passengers": []map[string]string{{"firstName": "A", "lastName": "B"}},
}
rec := a.do(t, http.MethodPost, "/api/bookings", body)
requireStatus(t, rec, http.StatusBadRequest)
}
func TestCreateBookingRejectsMalformedJSON(t *testing.T) {
a := newAPI(t)
req := httptest.NewRequest(http.MethodPost, "/api/bookings", bytes.NewReader([]byte("{not json")))
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString(
[]byte(a.Agent.Email+":"+testsupport.Password)))
req.Header.Set(middleware.TenantHeader, a.Tenant.Slug)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
a.handler.ServeHTTP(rec, req)
requireStatus(t, rec, http.StatusBadRequest)
}
func TestCreateBookingOverCapacityIsConflict(t *testing.T) {
a := newAPI(t)
two := 2
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 10, Available: &two})
rec := a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 3))
requireStatus(t, rec, http.StatusConflict)
}
func TestCreateBookingOnAnotherTenantsFlightIsFourOhFour(t *testing.T) {
a := newAPI(t)
other := testsupport.Setup(t)
theirFlight := other.NewFlight(t, testsupport.FlightOpts{Seats: 50})
rec := a.do(t, http.MethodPost, "/api/bookings", newBooking(theirFlight.ID, 1))
requireStatus(t, rec, http.StatusNotFound)
}
func TestBookingLifecycleOverHTTP(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20, Price: 100})
created := decode[models.Booking](t, a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 2)))
fetched := decode[models.Booking](t, a.do(t, http.MethodGet, "/api/bookings/"+created.ID.String(), nil))
if fetched.BookingReference != created.BookingReference {
t.Errorf("fetched a different booking: %s vs %s", fetched.BookingReference, created.BookingReference)
}
listed := decode[struct {
Items []models.Booking `json:"items"`
Total int `json:"total"`
}](t, a.do(t, http.MethodGet, "/api/bookings", nil))
if listed.Total != 1 {
t.Errorf("list total = %d, want 1", listed.Total)
}
cancelled := decode[models.Booking](t,
a.do(t, http.MethodPost, "/api/bookings/"+created.ID.String()+"/cancel", nil))
if cancelled.Status != "cancelled" {
t.Errorf("status = %q, want cancelled", cancelled.Status)
}
if left := a.SeatsAvailable(t, flight.ID); left != 20 {
t.Errorf("seats after cancel = %d, want 20", left)
}
// A second cancel finds nothing to cancel.
again := a.do(t, http.MethodPost, "/api/bookings/"+created.ID.String()+"/cancel", nil)
requireStatus(t, again, http.StatusNotFound)
}
func TestListBookingsRejectsAnUnknownStatus(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/bookings?status=pending", nil)
requireStatus(t, rec, http.StatusBadRequest)
}
func TestBookingsAreInvisibleToOtherTenants(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20})
created := decode[models.Booking](t, a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 1)))
// A second agency, with valid credentials of its own, must see nothing.
other := testsupport.Setup(t)
rec := a.as(t, other.Agent.Email, testsupport.Password, other.Tenant.Slug,
http.MethodGet, "/api/bookings/"+created.ID.String(), nil)
requireStatus(t, rec, http.StatusNotFound)
list := decode[struct {
Total int `json:"total"`
}](t, a.as(t, other.Agent.Email, testsupport.Password, other.Tenant.Slug,
http.MethodGet, "/api/bookings", nil))
if list.Total != 0 {
t.Errorf("other tenant sees %d bookings, want 0", list.Total)
}
}
// --- dashboard and reference ----------------------------------------------
func TestDashboardSummarisesTheTenant(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20, Price: 250})
a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 2))
rec := a.do(t, http.MethodGet, "/api/dashboard", nil)
requireStatus(t, rec, http.StatusOK)
got := decode[models.DashboardStats](t, rec)
if got.ConfirmedBookings != 1 {
t.Errorf("ConfirmedBookings = %d, want 1", got.ConfirmedBookings)
}
if got.TotalRevenue != 500 {
t.Errorf("TotalRevenue = %v, want 500", got.TotalRevenue)
}
if len(got.RecentBookings) != 1 {
t.Errorf("RecentBookings = %d, want 1", len(got.RecentBookings))
}
}
func TestReferenceListsAreScopedToTheTenantNetwork(t *testing.T) {
a := newAPI(t)
a.NewFlight(t, testsupport.FlightOpts{Origin: "JFK", Destination: "LHR"})
airports := decode[[]models.Airport](t, a.do(t, http.MethodGet, "/api/airports", nil))
if len(airports) != 2 {
t.Errorf("got %d airports, want the 2 this tenant flies", len(airports))
}
airlines := decode[[]models.Airline](t, a.do(t, http.MethodGet, "/api/airlines", nil))
if len(airlines) != 1 {
t.Errorf("got %d airlines, want 1", len(airlines))
}
}
// --- routing ---------------------------------------------------------------
func TestUnknownRouteAndWrongMethod(t *testing.T) {
a := newAPI(t)
t.Run("unknown path", func(t *testing.T) {
rec := a.do(t, http.MethodGet, "/api/nonexistent", nil)
requireStatus(t, rec, http.StatusNotFound)
})
t.Run("wrong method", func(t *testing.T) {
rec := a.do(t, http.MethodDelete, "/api/flights", nil)
requireStatus(t, rec, http.StatusMethodNotAllowed)
})
}
func TestEveryJSONResponseCarriesItsContentType(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/flights", nil)
if got := rec.Header().Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q, want application/json", got)
}
}
+44
View File
@@ -0,0 +1,44 @@
package handlers
import (
"net/http"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// ListTenants backs the agency picker on the sign-in screen. It is
// unauthenticated by necessity -- the client must choose a tenant before it
// can present credentials -- so it returns nothing but slugs and display
// names.
func (h *Handler) ListTenants(w http.ResponseWriter, r *http.Request) {
tenants, err := h.Repo.ListTenants(r.Context())
if err != nil {
h.fail(w, err, "list tenants")
return
}
type publicTenant struct {
Slug string `json:"slug"`
Name string `json:"name"`
}
items := make([]publicTenant, 0, len(tenants))
for _, t := range tenants {
items = append(items, publicTenant{Slug: t.Slug, Name: t.Name})
}
writeJSON(w, http.StatusOK, items)
}
// Session echoes the authenticated caller back to the client. The Angular app
// calls it once at sign-in to validate the credentials it just collected, and
// again on reload to confirm the stored ones still work.
func (h *Handler) Session(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, struct {
User models.User `json:"user"`
Tenant models.Tenant `json:"tenant"`
}{User: id.User, Tenant: id.Tenant})
}