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:
@@ -0,0 +1,280 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user