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,262 @@
|
||||
import { expect, test, uniqueSurname } from '../fixtures/test';
|
||||
import { SKYLINE_AGENT } from '../fixtures/accounts';
|
||||
|
||||
test.beforeEach(async ({ signInAs, shell, search }) => {
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
await shell.goToSearch();
|
||||
await search.settled();
|
||||
});
|
||||
|
||||
test.describe('Booking a flight', () => {
|
||||
test('carries the fare and traveller count into checkout', async ({ search, checkout, page }) => {
|
||||
await search.searchRoute('JFK', 'LHR');
|
||||
await search.setTravellers(2);
|
||||
|
||||
const fare = await search.card(0).fare();
|
||||
await search.card(0).selectButton.click();
|
||||
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
|
||||
await expect(checkout.travellerFieldsets).toHaveCount(2);
|
||||
expect(await checkout.totalAmount()).toBeCloseTo(fare * 2, 2);
|
||||
});
|
||||
|
||||
test('reprices as travellers are added and removed', async ({ search, checkout, page }) => {
|
||||
const fare = await search.card(0).fare();
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
|
||||
expect(await checkout.totalAmount()).toBeCloseTo(fare, 2);
|
||||
|
||||
await checkout.addTraveller.click();
|
||||
await expect(checkout.travellerFieldsets).toHaveCount(2);
|
||||
expect(await checkout.totalAmount()).toBeCloseTo(fare * 2, 2);
|
||||
|
||||
await checkout.removeTraveller(1);
|
||||
await expect(checkout.travellerFieldsets).toHaveCount(1);
|
||||
expect(await checkout.totalAmount()).toBeCloseTo(fare, 2);
|
||||
});
|
||||
|
||||
test('refuses to submit with missing traveller names', async ({ search, checkout, page }) => {
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
|
||||
await checkout.confirm.click();
|
||||
|
||||
await expect(checkout.error).toBeVisible();
|
||||
await expect(page.locator('.pax .error').first()).toContainText('Required');
|
||||
// Still on the form, nothing sold.
|
||||
await expect(page).toHaveURL(/\/book\//);
|
||||
});
|
||||
|
||||
test('books, confirms, lists and cancels, returning the seats', async ({
|
||||
search,
|
||||
checkout,
|
||||
bookings,
|
||||
bookingDetail,
|
||||
shell,
|
||||
page,
|
||||
evidence,
|
||||
}) => {
|
||||
const surname = uniqueSurname();
|
||||
|
||||
await search.searchRoute('JFK', 'LHR');
|
||||
const card = search.card(0);
|
||||
const flightNumber = await card.flightNumber();
|
||||
const fare = await card.fare();
|
||||
const seatsBefore = await card.seatsLeft();
|
||||
|
||||
await evidence.step('Search results: JFK to LHR', {
|
||||
agency: 'Skyline Travel',
|
||||
route: 'JFK to LHR',
|
||||
matchingFlights: await search.totalResults(),
|
||||
cheapestFlight: flightNumber,
|
||||
fare: `$${fare.toFixed(2)}`,
|
||||
seatsAvailable: seatsBefore,
|
||||
});
|
||||
|
||||
await card.selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
|
||||
await checkout.fillTravellers([
|
||||
{ firstName: 'Dana', lastName: surname, dateOfBirth: '1988-04-12', passportNumber: 'X1234567' },
|
||||
{ firstName: 'Elias', lastName: surname },
|
||||
]);
|
||||
await checkout.contactPhone.fill('+1-555-0101');
|
||||
|
||||
const expectedTotal = await checkout.totalAmount();
|
||||
|
||||
await evidence.step('Checkout completed for two travellers', {
|
||||
travellers: `Dana ${surname}, Elias ${surname}`,
|
||||
farePerTraveller: `$${fare.toFixed(2)}`,
|
||||
total: `$${expectedTotal.toFixed(2)}`,
|
||||
contactPhone: '+1-555-0101',
|
||||
});
|
||||
|
||||
await checkout.confirmBooking();
|
||||
|
||||
// --- confirmation -------------------------------------------------------
|
||||
await expect(bookingDetail.confirmationBanner).toBeVisible();
|
||||
await expect(bookingDetail.confirmationBanner).toContainText('Booking confirmed');
|
||||
await expect(bookingDetail.statusBadge).toHaveText(/confirmed/i);
|
||||
await expect(bookingDetail.travellerRows).toHaveCount(2);
|
||||
await expect(bookingDetail.travellerRows.first()).toContainText(surname);
|
||||
|
||||
const reference = await bookingDetail.bookingReference();
|
||||
expect(reference).toMatch(/^[A-Z0-9]{6}$/);
|
||||
expect(Number((await bookingDetail.totalPaid.innerText()).replace(/[^0-9.]/g, ''))).toBeCloseTo(
|
||||
expectedTotal,
|
||||
2,
|
||||
);
|
||||
expect(expectedTotal).toBeCloseTo(fare * 2, 2);
|
||||
|
||||
await evidence.step('Booking confirmed', {
|
||||
bookingReference: reference,
|
||||
status: 'confirmed',
|
||||
travellers: 2,
|
||||
totalPaid: `$${expectedTotal.toFixed(2)}`,
|
||||
soldBy: SKYLINE_AGENT.fullName,
|
||||
});
|
||||
|
||||
// --- appears in the list ------------------------------------------------
|
||||
await shell.goToBookings();
|
||||
await expect(bookings.rowFor(reference)).toHaveCount(1);
|
||||
await expect(bookings.rowFor(reference)).toContainText(/confirmed/i);
|
||||
await expect(bookings.rowFor(reference)).toContainText(SKYLINE_AGENT.fullName);
|
||||
|
||||
await evidence.step('Booking appears in the agency list', {
|
||||
bookingReference: reference,
|
||||
visibleInList: true,
|
||||
});
|
||||
|
||||
// --- seats were taken out of inventory ----------------------------------
|
||||
await shell.goToSearch();
|
||||
await search.searchRoute('JFK', 'LHR');
|
||||
const seatsAfterSale = await search.cardForFlight(flightNumber).seatsLeft();
|
||||
expect(seatsAfterSale).toBe(seatsBefore - 2);
|
||||
|
||||
await evidence.step('Seats removed from inventory', {
|
||||
flight: flightNumber,
|
||||
seatsBefore,
|
||||
seatsAfterSale,
|
||||
difference: seatsBefore - seatsAfterSale,
|
||||
});
|
||||
|
||||
// --- cancel -------------------------------------------------------------
|
||||
await shell.goToBookings();
|
||||
await bookings.open(reference);
|
||||
await bookingDetail.cancel(reference);
|
||||
|
||||
await expect(bookingDetail.statusBadge).toHaveText(/cancelled/i);
|
||||
await expect(bookingDetail.cancelledNotice).toContainText('returned to inventory');
|
||||
await expect(bookingDetail.cancelButton).toHaveCount(0);
|
||||
|
||||
await evidence.step('Booking cancelled', {
|
||||
bookingReference: reference,
|
||||
status: 'cancelled',
|
||||
});
|
||||
|
||||
// --- seats came back ----------------------------------------------------
|
||||
await shell.goToSearch();
|
||||
await search.searchRoute('JFK', 'LHR');
|
||||
const seatsAfterCancel = await search.cardForFlight(flightNumber).seatsLeft();
|
||||
expect(seatsAfterCancel).toBe(seatsBefore);
|
||||
|
||||
await evidence.step('Seats returned to inventory', {
|
||||
flight: flightNumber,
|
||||
seatsBefore,
|
||||
seatsAfterSale,
|
||||
seatsAfterCancel,
|
||||
restored: seatsAfterCancel === seatsBefore,
|
||||
});
|
||||
});
|
||||
|
||||
test('a cancel can be backed out of', async ({ search, checkout, bookingDetail, page }) => {
|
||||
const surname = uniqueSurname('KEEP');
|
||||
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
await checkout.fillTravellers([{ firstName: 'Mina', lastName: surname }]);
|
||||
await checkout.confirmBooking();
|
||||
const reference = await bookingDetail.bookingReference();
|
||||
|
||||
await bookingDetail.cancelButton.click();
|
||||
await expect(bookingDetail.confirmCancelButton).toBeVisible();
|
||||
|
||||
await bookingDetail.keepButton.click();
|
||||
|
||||
await expect(bookingDetail.confirmCancelButton).toHaveCount(0);
|
||||
await expect(bookingDetail.statusBadge).toHaveText(/confirmed/i);
|
||||
|
||||
// Leave the data as we found it.
|
||||
await bookingDetail.cancel(reference);
|
||||
});
|
||||
|
||||
test('the bookings list filters by status', async ({
|
||||
search,
|
||||
checkout,
|
||||
bookings,
|
||||
bookingDetail,
|
||||
shell,
|
||||
page,
|
||||
}) => {
|
||||
const surname = uniqueSurname('FILTER');
|
||||
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
await checkout.fillTravellers([{ firstName: 'Otto', lastName: surname }]);
|
||||
await checkout.confirmBooking();
|
||||
const reference = await bookingDetail.bookingReference();
|
||||
|
||||
await shell.goToBookings();
|
||||
await bookings.filterBy('confirmed');
|
||||
await expect(bookings.rowFor(reference)).toHaveCount(1);
|
||||
|
||||
await bookings.filterBy('cancelled');
|
||||
await expect(bookings.rowFor(reference)).toHaveCount(0);
|
||||
|
||||
// Cancel it, then confirm it moves between the two filters.
|
||||
await bookings.filterBy('all');
|
||||
await bookings.open(reference);
|
||||
await bookingDetail.cancel(reference);
|
||||
|
||||
await shell.goToBookings();
|
||||
await bookings.filterBy('cancelled');
|
||||
await expect(bookings.rowFor(reference)).toHaveCount(1);
|
||||
|
||||
await bookings.filterBy('confirmed');
|
||||
await expect(bookings.rowFor(reference)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a booking opened directly shows no stale confirmation banner', async ({
|
||||
search,
|
||||
checkout,
|
||||
bookingDetail,
|
||||
bookings,
|
||||
shell,
|
||||
page,
|
||||
}) => {
|
||||
const surname = uniqueSurname('DIRECT');
|
||||
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
await checkout.fillTravellers([{ firstName: 'Rhea', lastName: surname }]);
|
||||
await checkout.confirmBooking();
|
||||
const reference = await bookingDetail.bookingReference();
|
||||
|
||||
// Re-entering from the list is not a fresh purchase.
|
||||
await shell.goToBookings();
|
||||
await bookings.open(reference);
|
||||
await expect(bookingDetail.confirmationBanner).toHaveCount(0);
|
||||
await expect(bookingDetail.statusBadge).toHaveText(/confirmed/i);
|
||||
|
||||
await bookingDetail.cancel(reference);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user