Add booking checkout and webhook coverage
Some checks failed
Deploy Holiday Property Booking / deploy (push) Failing after 4m54s
Playwright Holiday Property Booking / playwright (push) Failing after 2m32s
Test & Build Holiday Property Booking / test-build (push) Successful in 1m44s

This commit is contained in:
2026-05-26 09:08:43 +00:00
parent 0aaba14300
commit d1314f2181
9 changed files with 2753 additions and 3 deletions

View File

@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest';
import { POST } from './route';
import { createBookingCheckout } from '@/lib/payments';
vi.mock('@/lib/payments', () => ({
createBookingCheckout: vi.fn(),
}));
describe('POST /api/bookings/checkout', () => {
it('returns the checkout handoff payload on success', async () => {
vi.mocked(createBookingCheckout).mockResolvedValue({
bookingId: 'booking_123',
paymentId: 'payment_123',
checkoutUrl: 'http://localhost:3000/bookings/booking_123/checkout',
checkoutMode: 'mock',
quote: {
propertySlug: 'orchard-barn',
propertyName: 'Orchard Barn',
area: 'Rural retreat',
available: true,
nights: 3,
arrivalDate: '2026-06-22',
departureDate: '2026-06-25',
holdExpiresAt: '2026-06-22T12:30:00.000Z',
reasons: [],
nightlyRates: [],
priceBreakdown: [],
totalCents: 63000,
},
});
const response = await POST(
new Request('http://localhost:3000/api/bookings/checkout', {
method: 'POST',
body: JSON.stringify({
propertySlug: 'orchard-barn',
arrivalDate: '2026-06-22',
departureDate: '2026-06-25',
adults: 2,
children: 0,
pets: 1,
firstName: 'Casey',
lastName: 'Morgan',
email: 'casey@example.com',
termsAccepted: true,
}),
}),
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
ok: true,
bookingId: 'booking_123',
paymentId: 'payment_123',
checkoutMode: 'mock',
});
expect(createBookingCheckout).toHaveBeenCalledWith(
expect.objectContaining({
propertySlug: 'orchard-barn',
adults: 2,
pets: 1,
firstName: 'Casey',
email: 'casey@example.com',
termsAccepted: true,
}),
);
});
it('returns a 400 payload when checkout creation fails', async () => {
vi.mocked(createBookingCheckout).mockRejectedValue(new Error('Unknown property'));
const response = await POST(
new Request('http://localhost:3000/api/bookings/checkout', {
method: 'POST',
body: JSON.stringify({ propertySlug: 'missing' }),
}),
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
ok: false,
error: 'Unknown property',
});
});
});

View File

@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from 'vitest';
import { POST } from './route';
import { handleStripeWebhookBody } from '@/lib/payments';
vi.mock('@/lib/payments', () => ({
handleStripeWebhookBody: vi.fn(),
}));
describe('POST /api/stripe/webhook', () => {
it('passes the raw request body and Stripe signature through to the webhook handler', async () => {
vi.mocked(handleStripeWebhookBody).mockResolvedValue({
bookingId: 'booking_123',
paymentId: 'payment_123',
status: 'CONFIRMED',
notification: null,
});
const response = await POST(
new Request('http://localhost:3000/api/stripe/webhook', {
method: 'POST',
headers: {
'stripe-signature': 'sig_test_123',
},
body: '{"id":"evt_123"}',
}),
);
expect(handleStripeWebhookBody).toHaveBeenCalledWith('{"id":"evt_123"}', 'sig_test_123');
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
ok: true,
bookingId: 'booking_123',
status: 'CONFIRMED',
});
});
it('returns a 400 payload when webhook processing fails', async () => {
vi.mocked(handleStripeWebhookBody).mockRejectedValue(new Error('Webhook payload did not include booking metadata'));
const response = await POST(
new Request('http://localhost:3000/api/stripe/webhook', {
method: 'POST',
body: '{}',
}),
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
ok: false,
error: 'Webhook payload did not include booking metadata',
});
});
});

View File

@@ -0,0 +1,56 @@
import type { ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import BookingCheckoutPage from './page';
import { getBookingCheckoutContext } from '@/lib/payments';
vi.mock('next/link', () => ({
default: ({ href, children, ...props }: { href: string; children: ReactNode }) => (
<a href={href} {...props}>
{children}
</a>
),
}));
vi.mock('next/navigation', () => ({
notFound: vi.fn(() => {
throw new Error('NEXT_NOT_FOUND');
}),
}));
vi.mock('@/lib/payments', () => ({
getBookingCheckoutContext: vi.fn(),
}));
describe('booking checkout page', () => {
it('renders the Stripe handoff and local fallback guidance', async () => {
vi.mocked(getBookingCheckoutContext).mockResolvedValue({
id: 'booking_123',
firstName: 'Casey',
lastName: 'Morgan',
email: 'casey@example.com',
arrivalDate: new Date('2026-06-22T00:00:00.000Z'),
departureDate: new Date('2026-06-25T00:00:00.000Z'),
totalCents: 63000,
property: {
title: 'Orchard Barn',
},
payment: {
status: 'REQUIRES_PAYMENT',
},
} as Awaited<ReturnType<typeof getBookingCheckoutContext>>);
const markup = renderToStaticMarkup(
await BookingCheckoutPage({
params: Promise.resolve({
bookingId: 'booking_123',
}),
}),
);
expect(markup).toContain('Finish payment for Orchard Barn');
expect(markup).toContain('Stripe Checkout collects payment when keys are configured.');
expect(markup).toContain('Open booking status');
expect(markup).toContain('/bookings/booking_123');
});
});

View File

@@ -0,0 +1,89 @@
import type { ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import BookingPage from './page';
import { getBookingCheckoutContext } from '@/lib/payments';
vi.mock('next/link', () => ({
default: ({ href, children, ...props }: { href: string; children: ReactNode }) => (
<a href={href} {...props}>
{children}
</a>
),
}));
vi.mock('next/navigation', () => ({
notFound: vi.fn(() => {
throw new Error('NEXT_NOT_FOUND');
}),
}));
vi.mock('@/lib/payments', () => ({
getBookingCheckoutContext: vi.fn(),
}));
function makeBooking(paymentStatus: 'REQUIRES_PAYMENT' | 'COMPLETED' | 'FAILED', bookingStatus: 'PENDING_PAYMENT' | 'CONFIRMED' | 'FAILED') {
return {
id: 'booking_123',
status: bookingStatus,
firstName: 'Casey',
lastName: 'Morgan',
email: 'casey@example.com',
phone: null,
holdExpiresAt: new Date('2026-06-22T12:30:00.000Z'),
arrivalDate: new Date('2026-06-22T00:00:00.000Z'),
departureDate: new Date('2026-06-25T00:00:00.000Z'),
totalCents: 63000,
property: {
title: 'Orchard Barn',
},
payment: {
status: paymentStatus,
},
} as Awaited<ReturnType<typeof getBookingCheckoutContext>>;
}
describe('booking status page', () => {
it('shows the return-state warning until the webhook confirms the booking', async () => {
vi.mocked(getBookingCheckoutContext).mockResolvedValue(makeBooking('REQUIRES_PAYMENT', 'PENDING_PAYMENT'));
const markup = renderToStaticMarkup(
await BookingPage({
params: Promise.resolve({ bookingId: 'booking_123' }),
searchParams: Promise.resolve({ checkout: 'success' }),
}),
);
expect(markup).toContain('Return from checkout');
expect(markup).toContain('only final once the payment record shows a completed webhook event');
expect(markup).toContain('Simulate successful payment');
});
it('shows a confirmed state after payment completion', async () => {
vi.mocked(getBookingCheckoutContext).mockResolvedValue(makeBooking('COMPLETED', 'CONFIRMED'));
const markup = renderToStaticMarkup(
await BookingPage({
params: Promise.resolve({ bookingId: 'booking_123' }),
searchParams: Promise.resolve({}),
}),
);
expect(markup).toContain('Payment verified');
expect(markup).not.toContain('Simulate successful payment');
});
it('shows a failed payment outcome when checkout is cancelled or payment fails', async () => {
vi.mocked(getBookingCheckoutContext).mockResolvedValue(makeBooking('FAILED', 'FAILED'));
const markup = renderToStaticMarkup(
await BookingPage({
params: Promise.resolve({ bookingId: 'booking_123' }),
searchParams: Promise.resolve({ checkout: 'cancelled' }),
}),
);
expect(markup).toContain('Payment not completed');
expect(markup).toContain('This booking remains unconfirmed');
});
});

View File

@@ -35,6 +35,8 @@ export default async function BookingPage({ params, searchParams }: BookingPageP
}
const paymentStatus = booking.payment?.status ?? 'REQUIRES_PAYMENT';
const paymentCompleted = paymentStatus === 'COMPLETED';
const paymentFailed = paymentStatus === 'FAILED' || booking.status === 'FAILED' || query.checkout === 'cancelled';
return (
<>
@@ -75,7 +77,16 @@ export default async function BookingPage({ params, searchParams }: BookingPageP
</article>
) : null}
{booking.payment?.status !== 'COMPLETED' ? (
{paymentFailed ? (
<article className="content-card" style={{ marginTop: '1rem' }}>
<h3>Payment not completed</h3>
<p className="mb-0">
Checkout did not complete successfully. This booking remains unconfirmed until a new successful payment event is recorded.
</p>
</article>
) : null}
{!paymentCompleted ? (
<article className="content-card" style={{ marginTop: '1rem' }}>
<h3>Development fallback</h3>
<p>

206
src/lib/payments.test.ts Normal file
View File

@@ -0,0 +1,206 @@
import { BookingStatus, PaymentStatus } from '@prisma/client';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const prismaMock = vi.hoisted(() => ({
property: {
upsert: vi.fn(),
},
booking: {
create: vi.fn(),
update: vi.fn(),
findUnique: vi.fn(),
},
payment: {
create: vi.fn(),
update: vi.fn(),
},
$transaction: vi.fn(async (operations: Array<Promise<unknown>>) => Promise.all(operations)),
}));
vi.mock('@/lib/prisma', () => ({
prisma: prismaMock,
}));
import { createBookingCheckout, handleStripeWebhookBody, handleStripeWebhookEvent } from './payments';
describe('payments flow', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'info').mockImplementation(() => {});
});
it('creates a booking checkout with the local fallback handoff when Stripe is not configured', async () => {
prismaMock.property.upsert.mockResolvedValue({ id: 'property_123' });
prismaMock.booking.create.mockResolvedValue({ id: 'booking_123' });
prismaMock.payment.create.mockResolvedValue({ id: 'payment_123' });
const result = await createBookingCheckout({
propertySlug: 'orchard-barn',
arrivalDate: '2026-06-22',
departureDate: '2026-06-25',
adults: 2,
children: 0,
pets: 1,
firstName: 'Casey',
lastName: 'Morgan',
email: 'casey@example.com',
termsAccepted: true,
});
expect(prismaMock.booking.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
propertyId: 'property_123',
status: BookingStatus.PENDING_PAYMENT,
totalCents: expect.any(Number),
currency: 'GBP',
}),
}),
);
expect(prismaMock.payment.create).toHaveBeenCalledWith({
data: {
bookingId: 'booking_123',
amountCents: result.quote.totalCents,
currency: 'GBP',
status: PaymentStatus.REQUIRES_PAYMENT,
},
});
expect(result).toMatchObject({
bookingId: 'booking_123',
paymentId: 'payment_123',
checkoutMode: 'mock',
checkoutUrl: 'http://localhost:3000/bookings/booking_123/checkout',
});
});
it('marks the booking confirmed when Stripe reports a successful payment event', async () => {
prismaMock.payment.update.mockResolvedValue({ id: 'payment_123' });
prismaMock.booking.update.mockResolvedValue({ id: 'booking_123', status: BookingStatus.CONFIRMED });
prismaMock.booking.findUnique.mockResolvedValue({
firstName: 'Casey',
lastName: 'Morgan',
email: 'casey@example.com',
arrivalDate: new Date('2026-06-22T00:00:00.000Z'),
departureDate: new Date('2026-06-25T00:00:00.000Z'),
totalCents: 63000,
status: BookingStatus.CONFIRMED,
property: {
title: 'Orchard Barn',
},
payment: {
status: PaymentStatus.COMPLETED,
},
});
const result = await handleStripeWebhookEvent({
id: 'evt_success_123',
type: 'checkout.session.completed',
data: {
object: {
id: 'cs_test_123',
payment_intent: 'pi_test_123',
metadata: {
bookingId: 'booking_123',
},
},
},
});
expect(prismaMock.payment.update).toHaveBeenCalledWith({
where: { bookingId: 'booking_123' },
data: {
status: PaymentStatus.COMPLETED,
stripeEventId: 'evt_success_123',
stripeCheckoutSessionId: 'cs_test_123',
stripePaymentIntentId: 'pi_test_123',
},
});
expect(prismaMock.booking.update).toHaveBeenCalledWith({
where: { id: 'booking_123' },
data: {
status: BookingStatus.CONFIRMED,
},
});
expect(result).toMatchObject({
bookingId: 'booking_123',
paymentId: 'payment_123',
status: BookingStatus.CONFIRMED,
notification: expect.objectContaining({
subject: 'Booking confirmed: Orchard Barn',
}),
});
});
it('marks the booking failed when Stripe reports an expired or failed payment', async () => {
prismaMock.payment.update.mockResolvedValue({ id: 'payment_123' });
prismaMock.booking.update.mockResolvedValue({ id: 'booking_123', status: BookingStatus.FAILED });
prismaMock.booking.findUnique.mockResolvedValue({
firstName: 'Casey',
lastName: 'Morgan',
email: 'casey@example.com',
arrivalDate: new Date('2026-06-22T00:00:00.000Z'),
departureDate: new Date('2026-06-25T00:00:00.000Z'),
totalCents: 63000,
status: BookingStatus.FAILED,
property: {
title: 'Orchard Barn',
},
payment: {
status: PaymentStatus.FAILED,
},
});
const result = await handleStripeWebhookBody(
JSON.stringify({
id: 'evt_failed_123',
type: 'payment_intent.payment_failed',
data: {
object: {
id: 'pi_test_123',
payment_intent: 'pi_test_123',
metadata: {
bookingId: 'booking_123',
},
},
},
}),
null,
);
expect(prismaMock.payment.update).toHaveBeenCalledWith({
where: { bookingId: 'booking_123' },
data: {
status: PaymentStatus.FAILED,
stripeEventId: 'evt_failed_123',
stripeCheckoutSessionId: 'pi_test_123',
stripePaymentIntentId: 'pi_test_123',
},
});
expect(prismaMock.booking.update).toHaveBeenCalledWith({
where: { id: 'booking_123' },
data: {
status: BookingStatus.FAILED,
},
});
expect(result).toMatchObject({
bookingId: 'booking_123',
paymentId: 'payment_123',
status: BookingStatus.FAILED,
notification: expect.objectContaining({
subject: 'Payment issue for Orchard Barn',
}),
});
});
it('rejects webhook payloads that do not include booking metadata', async () => {
await expect(
handleStripeWebhookEvent({
id: 'evt_missing_123',
type: 'checkout.session.completed',
data: {
object: {},
},
}),
).rejects.toThrow('Webhook payload did not include booking metadata');
});
});