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