Files
TS_Proto/backend/internal/repository/flight_repo_test.go
T
nilesh.pandClaude Opus 4.8 4d3eb18a6c 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>
2026-09-13 11:38:01 +05:30

281 lines
8.4 KiB
Go

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