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