package repository_test import ( "context" "testing" "time" "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" ) func TestSearchFlightsFiltersByRoute(t *testing.T) { f := testsupport.Setup(t) ctx := context.Background() want := f.NewFlight(t, testsupport.FlightOpts{Origin: "JFK", Destination: "LHR"}) f.NewFlight(t, testsupport.FlightOpts{Origin: "LAX", Destination: "DXB"}) got, total, err := f.Repo.SearchFlights(ctx, f.Tenant.ID, models.FlightSearch{ Origin: "JFK", Destination: "LHR", }) if err != nil { t.Fatalf("search: %v", err) } if total != 1 || len(got) != 1 { t.Fatalf("want 1 flight, got %d (total %d)", len(got), total) } if got[0].ID != want.ID { t.Errorf("wrong flight: got %s want %s", got[0].ID, want.ID) } } func TestSearchFlightsLowercaseIATAStillMatches(t *testing.T) { f := testsupport.Setup(t) f.NewFlight(t, testsupport.FlightOpts{Origin: "JFK", Destination: "LHR"}) // The repository upper-cases codes in SQL, so a sloppy client still works. got, _, err := f.Repo.SearchFlights(context.Background(), f.Tenant.ID, models.FlightSearch{ Origin: "jfk", Destination: "lhr", }) if err != nil { t.Fatalf("search: %v", err) } if len(got) != 1 { t.Fatalf("want 1 flight for lowercase codes, got %d", len(got)) } } // The headline guarantee: one agency's inventory is invisible to another. func TestSearchFlightsIsolatesTenants(t *testing.T) { mine := testsupport.Setup(t) theirs := testsupport.Setup(t) ctx := context.Background() mine.NewFlight(t, testsupport.FlightOpts{Number: "MINE1"}) theirs.NewFlight(t, testsupport.FlightOpts{Number: "THEIRS1"}) theirs.NewFlight(t, testsupport.FlightOpts{Number: "THEIRS2"}) got, total, err := mine.Repo.SearchFlights(ctx, mine.Tenant.ID, models.FlightSearch{}) if err != nil { t.Fatalf("search: %v", err) } if total != 1 || len(got) != 1 { t.Fatalf("tenant should see exactly its own 1 flight, saw %d (total %d)", len(got), total) } if got[0].FlightNumber != "MINE1" { t.Errorf("leaked another tenant's flight: %s", got[0].FlightNumber) } } func TestFlightByIDAcrossTenantsIsNotFound(t *testing.T) { mine := testsupport.Setup(t) theirs := testsupport.Setup(t) other := theirs.NewFlight(t, testsupport.FlightOpts{}) // Even holding a valid id, the wrong tenant must get ErrNotFound -- not a // permission error, which would confirm the record exists. _, err := mine.Repo.FlightByID(context.Background(), mine.Tenant.ID, other.ID) if err != repository.ErrNotFound { t.Fatalf("want ErrNotFound for cross-tenant read, got %v", err) } } func TestFlightByIDUnknownIsNotFound(t *testing.T) { f := testsupport.Setup(t) _, err := f.Repo.FlightByID(context.Background(), f.Tenant.ID, uuid.New()) if err != repository.ErrNotFound { t.Fatalf("want ErrNotFound, got %v", err) } } func TestSearchFlightsHidesDepartedAndCancelled(t *testing.T) { f := testsupport.Setup(t) f.NewFlight(t, testsupport.FlightOpts{Number: "FUTURE", DepartsIn: 24 * time.Hour}) f.NewFlight(t, testsupport.FlightOpts{Number: "PAST", DepartsIn: -24 * time.Hour}) f.NewFlight(t, testsupport.FlightOpts{Number: "GONE", Status: "cancelled"}) got, _, err := f.Repo.SearchFlights(context.Background(), f.Tenant.ID, models.FlightSearch{}) if err != nil { t.Fatalf("search: %v", err) } if len(got) != 1 || got[0].FlightNumber != "FUTURE" { names := make([]string, len(got)) for i, fl := range got { names[i] = fl.FlightNumber } t.Fatalf("want only FUTURE, got %v", names) } } func TestSearchFlightsRespectsPartySize(t *testing.T) { f := testsupport.Setup(t) two := 2 five := 5 f.NewFlight(t, testsupport.FlightOpts{Number: "TIGHT", Seats: 10, Available: &two}) f.NewFlight(t, testsupport.FlightOpts{Number: "ROOMY", Seats: 10, Available: &five}) got, _, err := f.Repo.SearchFlights(context.Background(), f.Tenant.ID, models.FlightSearch{ Passengers: 4, }) if err != nil { t.Fatalf("search: %v", err) } if len(got) != 1 || got[0].FlightNumber != "ROOMY" { t.Fatalf("a party of 4 should only see ROOMY, got %d results", len(got)) } } func TestSearchFlightsFiltersByCabin(t *testing.T) { f := testsupport.Setup(t) f.NewFlight(t, testsupport.FlightOpts{Number: "ECON", Cabin: "economy"}) f.NewFlight(t, testsupport.FlightOpts{Number: "BIZ", Cabin: "business"}) got, _, err := f.Repo.SearchFlights(context.Background(), f.Tenant.ID, models.FlightSearch{ CabinClass: "business", }) if err != nil { t.Fatalf("search: %v", err) } if len(got) != 1 || got[0].FlightNumber != "BIZ" { t.Fatalf("want only the business flight, got %d results", len(got)) } } func TestSearchFlightsFiltersByDepartureDay(t *testing.T) { f := testsupport.Setup(t) // Land both flights at a fixed hour so the test cannot straddle midnight. tomorrow := time.Now().AddDate(0, 0, 1) target := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 9, 0, 0, 0, time.Local) f.NewFlight(t, testsupport.FlightOpts{Number: "ONDAY", DepartsIn: time.Until(target)}) f.NewFlight(t, testsupport.FlightOpts{Number: "OFFDAY", DepartsIn: time.Until(target.AddDate(0, 0, 3))}) day := time.Date(target.Year(), target.Month(), target.Day(), 0, 0, 0, 0, time.Local) got, _, err := f.Repo.SearchFlights(context.Background(), f.Tenant.ID, models.FlightSearch{ DepartDate: &day, }) if err != nil { t.Fatalf("search: %v", err) } if len(got) != 1 || got[0].FlightNumber != "ONDAY" { t.Fatalf("want only ONDAY for that calendar day, got %d results", len(got)) } } func TestSearchFlightsSorting(t *testing.T) { f := testsupport.Setup(t) ctx := context.Background() f.NewFlight(t, testsupport.FlightOpts{ Number: "CHEAPSLOW", Price: 100, Duration: 12 * time.Hour, DepartsIn: 72 * time.Hour, }) f.NewFlight(t, testsupport.FlightOpts{ Number: "DEARFAST", Price: 900, Duration: 2 * time.Hour, DepartsIn: 24 * time.Hour, }) cases := []struct { sortBy string want string }{ {"price", "CHEAPSLOW"}, {"duration", "DEARFAST"}, {"departure", "DEARFAST"}, {"nonsense-falls-back-to-price", "CHEAPSLOW"}, } for _, tc := range cases { t.Run(tc.sortBy, func(t *testing.T) { got, _, err := f.Repo.SearchFlights(ctx, f.Tenant.ID, models.FlightSearch{SortBy: tc.sortBy}) if err != nil { t.Fatalf("search: %v", err) } if len(got) == 0 { t.Fatal("no results") } if got[0].FlightNumber != tc.want { t.Errorf("sort %q: first result %s, want %s", tc.sortBy, got[0].FlightNumber, tc.want) } }) } } func TestSearchFlightsPaginates(t *testing.T) { f := testsupport.Setup(t) ctx := context.Background() for i := 0; i < 5; i++ { f.NewFlight(t, testsupport.FlightOpts{Price: float64(100 * (i + 1))}) } first, total, err := f.Repo.SearchFlights(ctx, f.Tenant.ID, models.FlightSearch{Limit: 2, Offset: 0}) if err != nil { t.Fatalf("search: %v", err) } if total != 5 { t.Errorf("total = %d, want 5", total) } if len(first) != 2 { t.Fatalf("page size = %d, want 2", len(first)) } second, _, err := f.Repo.SearchFlights(ctx, f.Tenant.ID, models.FlightSearch{Limit: 2, Offset: 2}) if err != nil { t.Fatalf("search page 2: %v", err) } if len(second) != 2 { t.Fatalf("second page size = %d, want 2", len(second)) } if first[0].ID == second[0].ID { t.Error("second page repeated the first page's first row") } } func TestSearchFlightsComputesDuration(t *testing.T) { f := testsupport.Setup(t) f.NewFlight(t, testsupport.FlightOpts{Duration: 7*time.Hour + 25*time.Minute}) got, _, err := f.Repo.SearchFlights(context.Background(), f.Tenant.ID, models.FlightSearch{}) if err != nil { t.Fatalf("search: %v", err) } if got[0].DurationMinutes != 445 { t.Errorf("DurationMinutes = %d, want 445", got[0].DurationMinutes) } } func TestListAirportsCoversOnlyThisTenantsNetwork(t *testing.T) { mine := testsupport.Setup(t) theirs := testsupport.Setup(t) mine.NewFlight(t, testsupport.FlightOpts{Origin: "JFK", Destination: "LHR"}) theirs.NewFlight(t, testsupport.FlightOpts{Origin: "LAX", Destination: "DXB"}) airports, err := mine.Repo.ListAirports(context.Background(), mine.Tenant.ID) if err != nil { t.Fatalf("list airports: %v", err) } seen := map[string]bool{} for _, a := range airports { seen[a.IATACode] = true } if !seen["JFK"] || !seen["LHR"] { t.Errorf("want JFK and LHR in the network, got %v", seen) } if seen["LAX"] || seen["DXB"] { t.Errorf("leaked the other tenant's airports: %v", seen) } }