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,330 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/travelswitch/travelhub/backend/internal/models"
|
||||
)
|
||||
|
||||
// bookingSelect projects a booking together with its flight and the agent who
|
||||
// sold it.
|
||||
const bookingSelect = `
|
||||
SELECT b.id, b.booking_reference, b.passenger_count, b.total_amount,
|
||||
b.currency, b.status, b.contact_email, b.contact_phone, b.created_at,
|
||||
u.full_name,
|
||||
f.id, f.flight_number, f.departure_time, f.arrival_time, f.aircraft,
|
||||
f.cabin_class, f.base_price, f.currency, f.seats_total,
|
||||
f.seats_available, f.status,
|
||||
al.id, al.iata_code, al.name, COALESCE(al.logo_url, ''),
|
||||
o.id, o.iata_code, o.name, o.city, o.country, o.timezone,
|
||||
d.id, d.iata_code, d.name, d.city, d.country, d.timezone
|
||||
FROM bookings b
|
||||
JOIN users u ON u.id = b.user_id
|
||||
JOIN flights f ON f.id = b.flight_id
|
||||
JOIN airlines al ON al.id = f.airline_id
|
||||
JOIN airports o ON o.id = f.origin_airport_id
|
||||
JOIN airports d ON d.id = f.destination_airport_id`
|
||||
|
||||
func scanBooking(row pgx.Row) (models.Booking, error) {
|
||||
var b models.Booking
|
||||
f := &b.Flight
|
||||
err := row.Scan(
|
||||
&b.ID, &b.BookingReference, &b.PassengerCount, &b.TotalAmount,
|
||||
&b.Currency, &b.Status, &b.ContactEmail, &b.ContactPhone, &b.CreatedAt,
|
||||
&b.BookedBy,
|
||||
&f.ID, &f.FlightNumber, &f.DepartureTime, &f.ArrivalTime, &f.Aircraft,
|
||||
&f.CabinClass, &f.BasePrice, &f.Currency, &f.SeatsTotal,
|
||||
&f.SeatsAvailable, &f.Status,
|
||||
&f.Airline.ID, &f.Airline.IATACode, &f.Airline.Name, &f.Airline.LogoURL,
|
||||
&f.Origin.ID, &f.Origin.IATACode, &f.Origin.Name, &f.Origin.City,
|
||||
&f.Origin.Country, &f.Origin.Timezone,
|
||||
&f.Destination.ID, &f.Destination.IATACode, &f.Destination.Name,
|
||||
&f.Destination.City, &f.Destination.Country, &f.Destination.Timezone,
|
||||
)
|
||||
if err != nil {
|
||||
return models.Booking{}, err
|
||||
}
|
||||
f.DurationMinutes = int(f.ArrivalTime.Sub(f.DepartureTime).Minutes())
|
||||
b.Passengers = []models.Passenger{}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// pnrAlphabet omits I, O, 0 and 1 because agents read references aloud.
|
||||
const pnrAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
|
||||
func newPNR() (string, error) {
|
||||
out := make([]byte, 6)
|
||||
for i := range out {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(pnrAlphabet))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out[i] = pnrAlphabet[n.Int64()]
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// CreateBooking sells seats on a flight.
|
||||
//
|
||||
// The whole thing runs in one transaction and takes a row lock on the flight
|
||||
// (SELECT ... FOR UPDATE) before reading availability, so two agents booking
|
||||
// the last seats at the same moment cannot oversell: the second waits for the
|
||||
// first to commit and then sees the decremented count.
|
||||
func (r *Repository) CreateBooking(ctx context.Context, tenantID, userID uuid.UUID, req models.CreateBookingRequest) (models.Booking, error) {
|
||||
if len(req.Passengers) == 0 {
|
||||
return models.Booking{}, errors.New("at least one passenger is required")
|
||||
}
|
||||
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return models.Booking{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
|
||||
|
||||
var (
|
||||
price float64
|
||||
currency string
|
||||
available int
|
||||
)
|
||||
const lockSQL = `
|
||||
SELECT base_price, currency, seats_available
|
||||
FROM flights
|
||||
WHERE tenant_id = $1 AND id = $2 AND status <> 'cancelled'
|
||||
FOR UPDATE`
|
||||
|
||||
err = tx.QueryRow(ctx, lockSQL, tenantID, req.FlightID).Scan(&price, ¤cy, &available)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return models.Booking{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return models.Booking{}, fmt.Errorf("lock flight: %w", err)
|
||||
}
|
||||
|
||||
count := len(req.Passengers)
|
||||
if available < count {
|
||||
return models.Booking{}, ErrNoSeats
|
||||
}
|
||||
|
||||
const decSQL = `UPDATE flights SET seats_available = seats_available - $3
|
||||
WHERE tenant_id = $1 AND id = $2`
|
||||
if _, err := tx.Exec(ctx, decSQL, tenantID, req.FlightID, count); err != nil {
|
||||
return models.Booking{}, fmt.Errorf("reserve seats: %w", err)
|
||||
}
|
||||
|
||||
const insertSQL = `
|
||||
INSERT INTO bookings (tenant_id, user_id, flight_id, booking_reference,
|
||||
passenger_count, total_amount, currency,
|
||||
contact_email, contact_phone)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id`
|
||||
|
||||
// The PNR is unique per tenant; on the rare collision, draw again.
|
||||
var bookingID uuid.UUID
|
||||
for attempt := 0; attempt < 5; attempt++ {
|
||||
pnr, perr := newPNR()
|
||||
if perr != nil {
|
||||
return models.Booking{}, perr
|
||||
}
|
||||
err = tx.QueryRow(ctx, insertSQL,
|
||||
tenantID, userID, req.FlightID, pnr, count,
|
||||
price*float64(count), currency, req.ContactEmail, req.ContactPhone,
|
||||
).Scan(&bookingID)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if !isUniqueViolation(err) {
|
||||
return models.Booking{}, fmt.Errorf("insert booking: %w", err)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return models.Booking{}, fmt.Errorf("insert booking: %w", err)
|
||||
}
|
||||
|
||||
const paxSQL = `
|
||||
INSERT INTO passengers (tenant_id, booking_id, first_name, last_name,
|
||||
date_of_birth, passport_number)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`
|
||||
|
||||
for _, p := range req.Passengers {
|
||||
var dob *time.Time
|
||||
if p.DateOfBirth != "" {
|
||||
if parsed, perr := time.Parse("2006-01-02", p.DateOfBirth); perr == nil {
|
||||
dob = &parsed
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, paxSQL, tenantID, bookingID,
|
||||
p.FirstName, p.LastName, dob, p.PassportNumber); err != nil {
|
||||
return models.Booking{}, fmt.Errorf("insert passenger: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return models.Booking{}, err
|
||||
}
|
||||
|
||||
return r.BookingByID(ctx, tenantID, bookingID)
|
||||
}
|
||||
|
||||
// BookingByID returns one booking with its passengers.
|
||||
func (r *Repository) BookingByID(ctx context.Context, tenantID, bookingID uuid.UUID) (models.Booking, error) {
|
||||
q := bookingSelect + ` WHERE b.tenant_id = $1 AND b.id = $2`
|
||||
|
||||
b, err := scanBooking(r.pool.QueryRow(ctx, q, tenantID, bookingID))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return models.Booking{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return models.Booking{}, err
|
||||
}
|
||||
|
||||
pax, err := r.passengersFor(ctx, tenantID, []uuid.UUID{b.ID})
|
||||
if err != nil {
|
||||
return models.Booking{}, err
|
||||
}
|
||||
if list, ok := pax[b.ID]; ok {
|
||||
b.Passengers = list
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// ListBookings returns a tenant's bookings, newest first, with passengers
|
||||
// attached. Passengers are fetched in one extra query rather than one per
|
||||
// booking.
|
||||
func (r *Repository) ListBookings(ctx context.Context, tenantID uuid.UUID, status string, limit, offset int) ([]models.Booking, int, error) {
|
||||
where := "WHERE b.tenant_id = $1"
|
||||
args := []any{tenantID}
|
||||
if status != "" {
|
||||
args = append(args, status)
|
||||
where += fmt.Sprintf(" AND b.status = $%d", len(args))
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := r.pool.QueryRow(ctx, "SELECT count(*) FROM bookings b "+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count bookings: %w", err)
|
||||
}
|
||||
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 25
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
q := fmt.Sprintf("%s %s ORDER BY b.created_at DESC LIMIT $%d OFFSET $%d",
|
||||
bookingSelect, where, len(args)+1, len(args)+2)
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := r.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list bookings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
bookings := []models.Booking{}
|
||||
ids := []uuid.UUID{}
|
||||
for rows.Next() {
|
||||
b, err := scanBooking(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
bookings = append(bookings, b)
|
||||
ids = append(ids, b.ID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if len(ids) > 0 {
|
||||
pax, err := r.passengersFor(ctx, tenantID, ids)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for i := range bookings {
|
||||
if list, ok := pax[bookings[i].ID]; ok {
|
||||
bookings[i].Passengers = list
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bookings, total, nil
|
||||
}
|
||||
|
||||
// CancelBooking voids a booking and returns its seats to inventory.
|
||||
func (r *Repository) CancelBooking(ctx context.Context, tenantID, bookingID uuid.UUID) (models.Booking, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return models.Booking{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
|
||||
|
||||
var (
|
||||
flightID uuid.UUID
|
||||
count int
|
||||
)
|
||||
// Filtering on status here makes the cancel idempotent-safe: a second
|
||||
// cancel matches no row and returns ErrNotFound rather than crediting the
|
||||
// seats twice.
|
||||
const claimSQL = `
|
||||
UPDATE bookings SET status = 'cancelled'
|
||||
WHERE tenant_id = $1 AND id = $2 AND status = 'confirmed'
|
||||
RETURNING flight_id, passenger_count`
|
||||
|
||||
err = tx.QueryRow(ctx, claimSQL, tenantID, bookingID).Scan(&flightID, &count)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return models.Booking{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return models.Booking{}, fmt.Errorf("cancel booking: %w", err)
|
||||
}
|
||||
|
||||
const restoreSQL = `
|
||||
UPDATE flights
|
||||
SET seats_available = LEAST(seats_total, seats_available + $3)
|
||||
WHERE tenant_id = $1 AND id = $2`
|
||||
if _, err := tx.Exec(ctx, restoreSQL, tenantID, flightID, count); err != nil {
|
||||
return models.Booking{}, fmt.Errorf("restore seats: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return models.Booking{}, err
|
||||
}
|
||||
return r.BookingByID(ctx, tenantID, bookingID)
|
||||
}
|
||||
|
||||
// passengersFor loads passengers for several bookings at once, keyed by
|
||||
// booking id.
|
||||
func (r *Repository) passengersFor(ctx context.Context, tenantID uuid.UUID, bookingIDs []uuid.UUID) (map[uuid.UUID][]models.Passenger, error) {
|
||||
const q = `
|
||||
SELECT booking_id, id, first_name, last_name, date_of_birth,
|
||||
passport_number, seat_number
|
||||
FROM passengers
|
||||
WHERE tenant_id = $1 AND booking_id = ANY($2)
|
||||
ORDER BY last_name, first_name`
|
||||
|
||||
rows, err := r.pool.Query(ctx, q, tenantID, bookingIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list passengers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := map[uuid.UUID][]models.Passenger{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
bookingID uuid.UUID
|
||||
p models.Passenger
|
||||
)
|
||||
if err := rows.Scan(&bookingID, &p.ID, &p.FirstName, &p.LastName,
|
||||
&p.DateOfBirth, &p.PassportNumber, &p.SeatNumber); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[bookingID] = append(out[bookingID], p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/travelswitch/travelhub/backend/internal/models"
|
||||
)
|
||||
|
||||
// flightSelect is the projection shared by every flight read, joining the two
|
||||
// global reference tables so the API can return nested airline/airport objects.
|
||||
const flightSelect = `
|
||||
SELECT f.id, f.flight_number, f.departure_time, f.arrival_time, f.aircraft,
|
||||
f.cabin_class, f.base_price, f.currency, f.seats_total,
|
||||
f.seats_available, f.status,
|
||||
al.id, al.iata_code, al.name, COALESCE(al.logo_url, ''),
|
||||
o.id, o.iata_code, o.name, o.city, o.country, o.timezone,
|
||||
d.id, d.iata_code, d.name, d.city, d.country, d.timezone
|
||||
FROM flights f
|
||||
JOIN airlines al ON al.id = f.airline_id
|
||||
JOIN airports o ON o.id = f.origin_airport_id
|
||||
JOIN airports d ON d.id = f.destination_airport_id`
|
||||
|
||||
// scanFlight reads one row of flightSelect.
|
||||
func scanFlight(row pgx.Row) (models.Flight, error) {
|
||||
var f models.Flight
|
||||
err := row.Scan(
|
||||
&f.ID, &f.FlightNumber, &f.DepartureTime, &f.ArrivalTime, &f.Aircraft,
|
||||
&f.CabinClass, &f.BasePrice, &f.Currency, &f.SeatsTotal,
|
||||
&f.SeatsAvailable, &f.Status,
|
||||
&f.Airline.ID, &f.Airline.IATACode, &f.Airline.Name, &f.Airline.LogoURL,
|
||||
&f.Origin.ID, &f.Origin.IATACode, &f.Origin.Name, &f.Origin.City,
|
||||
&f.Origin.Country, &f.Origin.Timezone,
|
||||
&f.Destination.ID, &f.Destination.IATACode, &f.Destination.Name,
|
||||
&f.Destination.City, &f.Destination.Country, &f.Destination.Timezone,
|
||||
)
|
||||
if err != nil {
|
||||
return models.Flight{}, err
|
||||
}
|
||||
f.DurationMinutes = int(f.ArrivalTime.Sub(f.DepartureTime).Minutes())
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// searchFilters turns a FlightSearch into WHERE fragments plus their arguments.
|
||||
// tenantID is always $1, so every generated query is tenant-scoped by
|
||||
// construction rather than by the caller remembering to add it.
|
||||
func searchFilters(tenantID uuid.UUID, s models.FlightSearch) ([]string, []any) {
|
||||
where := []string{"f.tenant_id = $1", "f.status <> 'cancelled'", "f.departure_time > now()"}
|
||||
args := []any{tenantID}
|
||||
|
||||
add := func(clause string, value any) {
|
||||
args = append(args, value)
|
||||
where = append(where, fmt.Sprintf(clause, len(args)))
|
||||
}
|
||||
|
||||
// Airports are matched by IATA code through a subquery so the count query
|
||||
// does not need the joins that flightSelect carries.
|
||||
if s.Origin != "" {
|
||||
add("f.origin_airport_id = (SELECT id FROM airports WHERE iata_code = upper($%d))", s.Origin)
|
||||
}
|
||||
if s.Destination != "" {
|
||||
add("f.destination_airport_id = (SELECT id FROM airports WHERE iata_code = upper($%d))", s.Destination)
|
||||
}
|
||||
if s.CabinClass != "" {
|
||||
add("f.cabin_class = $%d", s.CabinClass)
|
||||
}
|
||||
if s.Passengers > 0 {
|
||||
add("f.seats_available >= $%d", s.Passengers)
|
||||
}
|
||||
if s.DepartDate != nil {
|
||||
// Half-open range keeps the composite index on departure_time usable.
|
||||
args = append(args, *s.DepartDate)
|
||||
where = append(where, fmt.Sprintf(
|
||||
"f.departure_time >= $%d AND f.departure_time < ($%d::timestamptz + interval '1 day')",
|
||||
len(args), len(args)))
|
||||
}
|
||||
|
||||
return where, args
|
||||
}
|
||||
|
||||
// orderClause whitelists sort options; anything unrecognised falls back to
|
||||
// price so a hostile sortBy value can never reach the SQL text.
|
||||
func orderClause(sortBy string) string {
|
||||
switch sortBy {
|
||||
case "duration":
|
||||
return "ORDER BY (f.arrival_time - f.departure_time) ASC, f.base_price ASC"
|
||||
case "departure":
|
||||
return "ORDER BY f.departure_time ASC, f.base_price ASC"
|
||||
default:
|
||||
return "ORDER BY f.base_price ASC, f.departure_time ASC"
|
||||
}
|
||||
}
|
||||
|
||||
// SearchFlights returns one page of a tenant's inventory plus the total count
|
||||
// matching the filters.
|
||||
func (r *Repository) SearchFlights(ctx context.Context, tenantID uuid.UUID, s models.FlightSearch) ([]models.Flight, int, error) {
|
||||
where, args := searchFilters(tenantID, s)
|
||||
whereSQL := "WHERE " + strings.Join(where, " AND ")
|
||||
|
||||
var total int
|
||||
countSQL := "SELECT count(*) FROM flights f " + whereSQL
|
||||
if err := r.pool.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count flights: %w", err)
|
||||
}
|
||||
|
||||
limit := s.Limit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 25
|
||||
}
|
||||
offset := s.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
pageArgs := append(append([]any{}, args...), limit, offset)
|
||||
listSQL := fmt.Sprintf("%s %s %s LIMIT $%d OFFSET $%d",
|
||||
flightSelect, whereSQL, orderClause(s.SortBy), len(args)+1, len(args)+2)
|
||||
|
||||
rows, err := r.pool.Query(ctx, listSQL, pageArgs...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list flights: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
flights := []models.Flight{}
|
||||
for rows.Next() {
|
||||
f, err := scanFlight(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
flights = append(flights, f)
|
||||
}
|
||||
return flights, total, rows.Err()
|
||||
}
|
||||
|
||||
// FlightByID fetches a single flight belonging to the tenant.
|
||||
func (r *Repository) FlightByID(ctx context.Context, tenantID, flightID uuid.UUID) (models.Flight, error) {
|
||||
q := flightSelect + ` WHERE f.tenant_id = $1 AND f.id = $2`
|
||||
|
||||
f, err := scanFlight(r.pool.QueryRow(ctx, q, tenantID, flightID))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return models.Flight{}, ErrNotFound
|
||||
}
|
||||
return f, err
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/travelswitch/travelhub/backend/internal/models"
|
||||
)
|
||||
|
||||
// TenantBySlug resolves the tenant a caller is signing in to. Suspended
|
||||
// tenants are treated as absent so their users cannot authenticate.
|
||||
func (r *Repository) TenantBySlug(ctx context.Context, slug string) (models.Tenant, error) {
|
||||
const q = `
|
||||
SELECT id, slug, name, status, created_at
|
||||
FROM tenants
|
||||
WHERE slug = $1 AND status = 'active'`
|
||||
|
||||
var t models.Tenant
|
||||
err := r.pool.QueryRow(ctx, q, slug).
|
||||
Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return models.Tenant{}, ErrNotFound
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
// ListTenants powers the tenant picker on the sign-in screen. It is the only
|
||||
// unauthenticated read in the service and deliberately exposes nothing beyond
|
||||
// the slug and display name.
|
||||
func (r *Repository) ListTenants(ctx context.Context) ([]models.Tenant, error) {
|
||||
const q = `
|
||||
SELECT id, slug, name, status, created_at
|
||||
FROM tenants
|
||||
WHERE status = 'active'
|
||||
ORDER BY name`
|
||||
|
||||
rows, err := r.pool.Query(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tenants := []models.Tenant{}
|
||||
for rows.Next() {
|
||||
var t models.Tenant
|
||||
if err := rows.Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenants = append(tenants, t)
|
||||
}
|
||||
return tenants, rows.Err()
|
||||
}
|
||||
|
||||
// UserByEmail looks up a login within one tenant. The tenant filter is what
|
||||
// stops credentials from one agency authenticating against another.
|
||||
func (r *Repository) UserByEmail(ctx context.Context, tenantID uuid.UUID, email string) (models.User, error) {
|
||||
const q = `
|
||||
SELECT id, tenant_id, email, password_hash, full_name, role, created_at
|
||||
FROM users
|
||||
WHERE tenant_id = $1 AND lower(email) = lower($2)`
|
||||
|
||||
var u models.User
|
||||
err := r.pool.QueryRow(ctx, q, tenantID, email).Scan(
|
||||
&u.ID, &u.TenantID, &u.Email, &u.PasswordHash, &u.FullName, &u.Role, &u.CreatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return models.User{}, ErrNotFound
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package repository_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/travelswitch/travelhub/backend/internal/repository"
|
||||
"github.com/travelswitch/travelhub/backend/internal/testsupport"
|
||||
)
|
||||
|
||||
func TestTenantBySlug(t *testing.T) {
|
||||
f := testsupport.Setup(t)
|
||||
|
||||
got, err := f.Repo.TenantBySlug(context.Background(), f.Tenant.Slug)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup: %v", err)
|
||||
}
|
||||
if got.ID != f.Tenant.ID {
|
||||
t.Errorf("got tenant %s, want %s", got.ID, f.Tenant.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantBySlugUnknownIsNotFound(t *testing.T) {
|
||||
f := testsupport.Setup(t)
|
||||
|
||||
if _, err := f.Repo.TenantBySlug(context.Background(), "no-such-agency"); err != repository.ErrNotFound {
|
||||
t.Fatalf("want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A suspended tenant must be indistinguishable from a missing one, so its
|
||||
// users cannot sign in.
|
||||
func TestTenantBySlugSuspendedIsNotFound(t *testing.T) {
|
||||
f := testsupport.Setup(t)
|
||||
f.Suspend(t)
|
||||
|
||||
if _, err := f.Repo.TenantBySlug(context.Background(), f.Tenant.Slug); err != repository.ErrNotFound {
|
||||
t.Fatalf("want ErrNotFound for a suspended tenant, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserByEmailIsCaseInsensitive(t *testing.T) {
|
||||
f := testsupport.Setup(t)
|
||||
|
||||
got, err := f.Repo.UserByEmail(context.Background(), f.Tenant.ID, strings.ToUpper(f.Agent.Email))
|
||||
if err != nil {
|
||||
t.Fatalf("lookup: %v", err)
|
||||
}
|
||||
if got.ID != f.Agent.ID {
|
||||
t.Errorf("got user %s, want %s", got.ID, f.Agent.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// The tenant filter on the user lookup is what stops one agency's credentials
|
||||
// from authenticating at another.
|
||||
func TestUserByEmailIsScopedToTenant(t *testing.T) {
|
||||
mine := testsupport.Setup(t)
|
||||
theirs := testsupport.Setup(t)
|
||||
|
||||
_, err := mine.Repo.UserByEmail(context.Background(), mine.Tenant.ID, theirs.Agent.Email)
|
||||
if err != repository.ErrNotFound {
|
||||
t.Fatalf("want ErrNotFound looking up another tenant's user, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The same address may hold an account at more than one agency; each must
|
||||
// resolve to its own row.
|
||||
func TestSameEmailCanExistInTwoTenants(t *testing.T) {
|
||||
a := testsupport.Setup(t)
|
||||
b := testsupport.Setup(t)
|
||||
const shared = "traveller@example.com"
|
||||
|
||||
inA := a.NewUser(t, shared, "agent")
|
||||
inB := b.NewUser(t, shared, "admin")
|
||||
|
||||
gotA, err := a.Repo.UserByEmail(context.Background(), a.Tenant.ID, shared)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup in A: %v", err)
|
||||
}
|
||||
gotB, err := b.Repo.UserByEmail(context.Background(), b.Tenant.ID, shared)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup in B: %v", err)
|
||||
}
|
||||
|
||||
if gotA.ID != inA.ID || gotB.ID != inB.ID {
|
||||
t.Error("shared email resolved to the wrong tenant's user")
|
||||
}
|
||||
if gotA.ID == gotB.ID {
|
||||
t.Error("the two tenants collapsed onto one user row")
|
||||
}
|
||||
if gotA.Role != "agent" || gotB.Role != "admin" {
|
||||
t.Errorf("roles crossed over: A=%s B=%s", gotA.Role, gotB.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTenantsExcludesSuspended(t *testing.T) {
|
||||
live := testsupport.Setup(t)
|
||||
dead := testsupport.Setup(t)
|
||||
dead.Suspend(t)
|
||||
|
||||
tenants, err := live.Repo.ListTenants(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, tn := range tenants {
|
||||
seen[tn.Slug] = true
|
||||
}
|
||||
if !seen[live.Tenant.Slug] {
|
||||
t.Error("active tenant missing from the sign-in picker")
|
||||
}
|
||||
if seen[dead.Tenant.Slug] {
|
||||
t.Error("suspended tenant offered in the sign-in picker")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/travelswitch/travelhub/backend/internal/models"
|
||||
)
|
||||
|
||||
// ListAirports returns the airports this tenant actually flies to or from,
|
||||
// which is what the search form's dropdowns should offer. Airports themselves
|
||||
// are global, but the filter keeps each agency's UI limited to its network.
|
||||
func (r *Repository) ListAirports(ctx context.Context, tenantID uuid.UUID) ([]models.Airport, error) {
|
||||
const q = `
|
||||
SELECT DISTINCT a.id, a.iata_code, a.name, a.city, a.country, a.timezone
|
||||
FROM airports a
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM flights f
|
||||
WHERE f.tenant_id = $1
|
||||
AND (f.origin_airport_id = a.id OR f.destination_airport_id = a.id)
|
||||
)
|
||||
ORDER BY a.city, a.iata_code`
|
||||
|
||||
rows, err := r.pool.Query(ctx, q, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
airports := []models.Airport{}
|
||||
for rows.Next() {
|
||||
var a models.Airport
|
||||
if err := rows.Scan(&a.ID, &a.IATACode, &a.Name, &a.City, &a.Country, &a.Timezone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
airports = append(airports, a)
|
||||
}
|
||||
return airports, rows.Err()
|
||||
}
|
||||
|
||||
// ListAirlines returns the carriers present in this tenant's inventory.
|
||||
func (r *Repository) ListAirlines(ctx context.Context, tenantID uuid.UUID) ([]models.Airline, error) {
|
||||
const q = `
|
||||
SELECT DISTINCT al.id, al.iata_code, al.name, COALESCE(al.logo_url, '')
|
||||
FROM airlines al
|
||||
JOIN flights f ON f.airline_id = al.id AND f.tenant_id = $1
|
||||
ORDER BY al.name`
|
||||
|
||||
rows, err := r.pool.Query(ctx, q, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
airlines := []models.Airline{}
|
||||
for rows.Next() {
|
||||
var a models.Airline
|
||||
if err := rows.Scan(&a.ID, &a.IATACode, &a.Name, &a.LogoURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
airlines = append(airlines, a)
|
||||
}
|
||||
return airlines, rows.Err()
|
||||
}
|
||||
|
||||
// DashboardStats aggregates one tenant's trading position.
|
||||
func (r *Repository) DashboardStats(ctx context.Context, tenantID uuid.UUID) (models.DashboardStats, error) {
|
||||
var s models.DashboardStats
|
||||
s.Currency = "USD"
|
||||
|
||||
const totalsSQL = `
|
||||
SELECT COUNT(*),
|
||||
COUNT(*) FILTER (WHERE status = 'confirmed'),
|
||||
COUNT(*) FILTER (WHERE status = 'cancelled'),
|
||||
COALESCE(SUM(total_amount) FILTER (WHERE status = 'confirmed'), 0),
|
||||
COALESCE(SUM(passenger_count) FILTER (WHERE status = 'confirmed'), 0)
|
||||
FROM bookings
|
||||
WHERE tenant_id = $1`
|
||||
|
||||
if err := r.pool.QueryRow(ctx, totalsSQL, tenantID).Scan(
|
||||
&s.TotalBookings, &s.ConfirmedBookings, &s.CancelledBookings,
|
||||
&s.TotalRevenue, &s.SeatsSold,
|
||||
); err != nil {
|
||||
return s, err
|
||||
}
|
||||
|
||||
const stockSQL = `
|
||||
SELECT COUNT(*) FROM flights
|
||||
WHERE tenant_id = $1 AND departure_time > now() AND status <> 'cancelled'`
|
||||
if err := r.pool.QueryRow(ctx, stockSQL, tenantID).Scan(&s.FlightsInStock); err != nil {
|
||||
return s, err
|
||||
}
|
||||
|
||||
const routesSQL = `
|
||||
SELECT o.iata_code || ' to ' || d.iata_code AS route,
|
||||
COUNT(*) AS bookings,
|
||||
COALESCE(SUM(b.total_amount), 0) AS revenue
|
||||
FROM bookings b
|
||||
JOIN flights f ON f.id = b.flight_id
|
||||
JOIN airports o ON o.id = f.origin_airport_id
|
||||
JOIN airports d ON d.id = f.destination_airport_id
|
||||
WHERE b.tenant_id = $1 AND b.status = 'confirmed'
|
||||
GROUP BY route
|
||||
ORDER BY revenue DESC
|
||||
LIMIT 5`
|
||||
|
||||
rows, err := r.pool.Query(ctx, routesSQL, tenantID)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var entry struct {
|
||||
Route string `json:"route"`
|
||||
Bookings int `json:"bookings"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
}
|
||||
if err := rows.Scan(&entry.Route, &entry.Bookings, &entry.Revenue); err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.TopRoutes = append(s.TopRoutes, entry)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return s, err
|
||||
}
|
||||
|
||||
recent, _, err := r.ListBookings(ctx, tenantID, "", 5, 0)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.RecentBookings = recent
|
||||
|
||||
return s, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Package repository holds every SQL statement in the service.
|
||||
//
|
||||
// Tenant isolation convention: any query touching a tenant-owned table
|
||||
// (users, flights, bookings, passengers) takes tenantID as its FIRST argument
|
||||
// and filters on it in the WHERE clause. Callers pass the id from the
|
||||
// authenticated identity, never from client input.
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a lookup matches no row within the tenant.
|
||||
// Handlers map it to 404, which also means a caller probing for another
|
||||
// tenant's record sees a plain "not found" rather than a permission error.
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// ErrNoSeats is returned when a booking asks for more seats than remain.
|
||||
var ErrNoSeats = errors.New("not enough seats available")
|
||||
|
||||
// Repository is the data access layer over a pgx pool.
|
||||
type Repository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// New builds a Repository around an open pool.
|
||||
func New(pool *pgxpool.Pool) *Repository {
|
||||
return &Repository{pool: pool}
|
||||
}
|
||||
|
||||
// isUniqueViolation reports whether err is PostgreSQL's 23505, which lets
|
||||
// callers retry a generated value instead of failing the request.
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
Reference in New Issue
Block a user