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>
281 lines
8.4 KiB
Go
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)
|
|
}
|
|
}
|