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) } }